1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-09-05 20:56:22 +02:00

Compare commits

...

51 Commits

Author SHA1 Message Date
Laurent Cozic
d0c4de92e2 Desktop release v2.0.6 2021-06-10 14:07:12 +02:00
Laurent Cozic
91ce465535 Desktop release v2.0.5 2021-06-10 14:06:09 +02:00
Laurent Cozic
4098c01e7c Merge branch 'dev' into release-2.0 2021-06-10 14:03:50 +02:00
Laurent
e617e6fab3 Tools: Migrated Continuous Integration to GitHub Actions (#5061)
And removed Travis
2021-06-10 13:01:55 +01:00
Laurent Cozic
5fd6571bf1 Desktop: Allow restoring a delete note from note history using command palette 2021-06-10 11:49:20 +02:00
Laurent Cozic
00dc1d881b Desktop: Allow passing arguments to commands in command palette 2021-06-10 11:46:41 +02:00
Laurent Cozic
c37eb56ed7 Tools: Fixed tests 2021-06-10 11:13:00 +02:00
Laurent Cozic
b2b6ad479a Revert "Desktop: Make font size consistent between Markdown and Rich Text editors"
This reverts commit a058e09183.

Reverts because this change means the settings are directly accessed
from the theme, which makes the themes unusable from Joplin Server.
2021-06-10 10:59:15 +02:00
Laurent Cozic
0e4c545e14 Tools: Fixed tests 2021-06-10 10:57:45 +02:00
Laurent Cozic
bbae1aef28 Merge branch 'dev' of github.com:laurent22/joplin into dev 2021-06-10 00:11:40 +02:00
Laurent Cozic
cf86ffc36e Plugin Repo: Update stats every 7 days 2021-06-10 00:11:11 +02:00
Roman Musin
9d80a79cda Android: Resolves #4216: Focus note editor where tapped instead of scrolling to the end (#4998) 2021-06-08 23:39:20 +01:00
Laurent Cozic
ca487ade9a Desktop: Add "Retry all" button to sync status screen for items that could not be uploaded 2021-06-08 22:36:10 +02:00
Laurent Cozic
75b66a9fff Merge branch 'dev' of github.com:laurent22/joplin into dev 2021-06-08 20:38:05 +02:00
Laurent Cozic
56fdf97693 Desktop: Fixes #5034: Certain resource paths could be corrupted when saved from the Rich Text editor 2021-06-08 20:37:44 +02:00
Subhra264
ce02a30441 Desktop, Mobile: Fixes #5025: Inline Katex gets broken when editing in Rich Text editor (#5052) 2021-06-08 19:23:10 +01:00
Laurent Cozic
a058e09183 Desktop: Make font size consistent between Markdown and Rich Text editors 2021-06-08 20:21:11 +02:00
Laurent Cozic
594084e274 Server: Fixed error when creating user 2021-06-08 12:39:18 +02:00
Laurent Cozic
5614eb9442 Server: Added option to enable or disable stack traces 2021-06-08 12:08:40 +02:00
Laurent Cozic
7a3a2084db Server: Add navbar on login and sign up page 2021-06-08 11:48:58 +02:00
Laurent Cozic
95d7ccccea Desktop: Improved Joplin Server error handling 2021-06-08 01:34:33 +02:00
Laurent Cozic
f7a7009b3c Server v2.0.6 2021-06-07 19:28:18 +02:00
Laurent Cozic
de7579a14e Merge branch 'dev' of github.com:laurent22/joplin into dev 2021-06-07 18:04:45 +02:00
Laurent Cozic
c8d7ecbf6c Server: Add request duration to log 2021-06-07 16:27:09 +02:00
Laurent Cozic
3c41b45e8e Server: Check share ID when uploading a note 2021-06-07 16:17:52 +02:00
mbalint
62a371b9f3 All: Resolves #4613: Improve search with Asian scripts (#5018) 2021-06-07 15:15:04 +01:00
Laurent Cozic
5528ab7cc8 Tools: Fixed tests 2021-06-07 15:46:35 +02:00
Laurent Cozic
824afd4809 Update website 2021-06-07 11:45:54 +02:00
Laurent Cozic
8ed1330d68 Doc: Added sponsor 2021-06-07 11:45:26 +02:00
Laurent Cozic
fec5d4b335 Update website 2021-06-07 11:40:20 +02:00
Laurent Cozic
e7b9103bfc Doc: Added sponsor 2021-06-07 11:39:34 +02:00
JackGruber
dd1c9e3c2a All: Fixes #5007: Items are filtered in the API search (#5017) 2021-06-07 10:21:24 +01:00
Roman Musin
7c45b95f6f Desktop: recreate http agent when the protocol changes (#5016) 2021-06-07 10:19:59 +01:00
Caleb John
a7e67952b8 Plugins: Support executing codemirror commands from plugins when using execCommand (#5012) 2021-06-07 10:19:35 +01:00
Austin Doupnik
1b7d40387d Desktop: Fixes #4877: Incorrect list renumbering (#4914) 2021-06-07 10:17:46 +01:00
Helmut K. C. Tessarek
7921e70c4f macOS: add 'Hide Others' and 'Show All' menu items (#5024) 2021-06-06 23:49:44 +01:00
col
8afac643ba Update README.md (#5057) 2021-06-06 22:10:41 +01:00
Laurent Cozic
23cfbc2367 Merge branch 'dev' of github.com:laurent22/joplin into dev 2021-06-06 19:14:48 +02:00
Laurent Cozic
de45740129 Server: Load shared user content from correct domain 2021-06-06 19:14:12 +02:00
Helmut K. C. Tessarek
a04d8ef441 Doc: fix text of terms and privacy (#5053) 2021-06-05 08:26:32 +01:00
Laurent Cozic
db7b802803 Server: Add terms and privacy page 2021-06-04 18:09:09 +02:00
Laurent Cozic
75d79f373a Server: Added way to disable signup page, and added links between signup and login pages 2021-06-04 17:08:21 +02:00
Laurent Cozic
e8a02c26d0 Desktop: Fixed: Ctrl+Clicking links in Rich Text editor was broken (regression) 2021-06-04 13:34:30 +02:00
Laurent Cozic
147b6b13ab package locks 2021-06-04 13:19:04 +02:00
Helmut K. C. Tessarek
a496a3d90d update en_US.po 2021-06-04 00:38:54 -04:00
Helmut K. C. Tessarek
69a8ada2ec add new translation strings 2021-06-04 00:34:19 -04:00
Ji-Hyeon Gim
87257870f4 All: Translation: Update ko.po (#5043)
It updates Korean translation to be better.

Signed-off-by: Ji-Hyeon Gim <potatogim@potatogim.net>
2021-06-03 20:41:21 -04:00
Laurent Cozic
21ea3253db Desktop: Add Joplin Cloud sync target 2021-06-03 17:12:07 +02:00
Laurent Cozic
770af6a53b Server: Add Stripe integration 2021-06-03 15:21:02 +02:00
Laurent Cozic
c88e4f6628 Tools: Trim white spaces in credential files 2021-06-02 21:59:53 +02:00
Laurent Cozic
2f79492192 Doc: Update travis-ci links 2021-06-02 14:58:27 +02:00
167 changed files with 18008 additions and 17106 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
@@ -338,6 +341,9 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js.map
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js.map
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js.map
@@ -821,6 +827,9 @@ packages/lib/Logger.js.map
packages/lib/PoorManIntervals.d.ts
packages/lib/PoorManIntervals.js
packages/lib/PoorManIntervals.js.map
packages/lib/SyncTargetJoplinCloud.d.ts
packages/lib/SyncTargetJoplinCloud.js
packages/lib/SyncTargetJoplinCloud.js.map
packages/lib/SyncTargetJoplinServer.d.ts
packages/lib/SyncTargetJoplinServer.js
packages/lib/SyncTargetJoplinServer.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"

9
.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
@@ -324,6 +327,9 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js.map
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js.map
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js.map
@@ -807,6 +813,9 @@ packages/lib/Logger.js.map
packages/lib/PoorManIntervals.d.ts
packages/lib/PoorManIntervals.js
packages/lib/PoorManIntervals.js.map
packages/lib/SyncTargetJoplinCloud.d.ts
packages/lib/SyncTargetJoplinCloud.js
packages/lib/SyncTargetJoplinCloud.js.map
packages/lib/SyncTargetJoplinServer.d.ts
packages/lib/SyncTargetJoplinServer.js
packages/lib/SyncTargetJoplinServer.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,4 +1,4 @@
[![Travis Build Status](https://travis-ci.org/laurent22/joplin.svg?branch=master)](https://travis-ci.org/laurent22/joplin) [![Appveyor Build Status](https://ci.appveyor.com/api/projects/status/github/laurent22/joplin?branch=master&passingText=master%20-%20OK&svg=true)](https://ci.appveyor.com/project/laurent22/joplin)
[![Travis Build Status](https://travis-ci.com/laurent22/joplin.svg?branch=master)](https://travis-ci.com/laurent22/joplin) [![Appveyor Build Status](https://ci.appveyor.com/api/projects/status/github/laurent22/joplin?branch=master&passingText=master%20-%20OK&svg=true)](https://ci.appveyor.com/project/laurent22/joplin)
# Building the applications

View File

@@ -64,7 +64,7 @@ The Web Clipper is a browser extension that allows you to save web pages and scr
# Sponsors
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="https://usrigging.com/"><img title="U.S. Ringing Supply" width="256" src="https://joplinapp.org/images/sponsors/RingingSupply.svg"/></a>
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://usrigging.com/"><img title="U.S. Ringing Supply" width="256" src="https://joplinapp.org/images/sponsors/RingingSupply.svg"/></a> <a href=" https://tranio.com/italy/"><img title="Tranio" width="256" src="https://joplinapp.org/images/sponsors/Tranio.png"/></a>
* * *
@@ -409,6 +409,12 @@ For more information see [Plugins](https://github.com/laurent22/joplin/blob/dev/
Joplin implements the SQLite Full Text Search (FTS4) extension. It means the content of all the notes is indexed in real time and search queries return results very fast. Both [Simple FTS Queries](https://www.sqlite.org/fts3.html#simple_fts_queries) and [Full-Text Index Queries](https://www.sqlite.org/fts3.html#full_text_index_queries) are supported. See below for the list of supported queries:
One caveat of SQLite FTS is that it does not support languages which do not use Latin word boundaries (spaces, tabs, punctuation). To solve this issue, Joplin has a custom search mode, that does not use FTS, but still has all of its features (multi term search, filters, etc.). One of its drawbacks is that it can get slow on larger note collections. Also, the sorting of the results will be less accurate, as the ranking algorithm (BM25) is, for now, only implemented for FTS. Finally, in this mode there are no restrictions on using the `*` wildcard (`swim*`, `*swim` and `ast*rix` all work). This search mode is currently enabled if one of the following languages are detected:
- Chinese
- Japanese
- Korean
- Thai
## Supported queries
Search type | Description | Example
@@ -516,44 +522,44 @@ Current translations:
<img src="https://joplinapp.org/images/flags/country-4x3/arableague.png" width="16px"/> | Arabic | [ar](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po) | [Whaell O](mailto:Whaell@protonmail.com) | 95%
<img src="https://joplinapp.org/images/flags/es/basque_country.png" width="16px"/> | Basque | [eu](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eu.po) | juan.abasolo@ehu.eus | 30%
<img src="https://joplinapp.org/images/flags/country-4x3/ba.png" width="16px"/> | Bosnian (Bosna i Hercegovina) | [bs_BA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bs_BA.po) | [Derviš T.](mailto:dervis.t@pm.me) | 74%
<img src="https://joplinapp.org/images/flags/country-4x3/bg.png" width="16px"/> | Bulgarian (България) | [bg_BG](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po) | | 58%
<img src="https://joplinapp.org/images/flags/country-4x3/bg.png" width="16px"/> | Bulgarian (България) | [bg_BG](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po) | | 57%
<img src="https://joplinapp.org/images/flags/es/catalonia.png" width="16px"/> | Catalan | [ca](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ca.po) | jmontane, 2019 | 82%
<img src="https://joplinapp.org/images/flags/country-4x3/hr.png" width="16px"/> | Croatian (Hrvatska) | [hr_HR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hr_HR.po) | [Milo Ivir](mailto:mail@milotype.de) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/cz.png" width="16px"/> | Czech (Česká republika) | [cs_CZ](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po) | [Lukas Helebrandt](mailto:lukas@aiya.cz) | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/hr.png" width="16px"/> | Croatian (Hrvatska) | [hr_HR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hr_HR.po) | [Milo Ivir](mailto:mail@milotype.de) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/cz.png" width="16px"/> | Czech (Česká republika) | [cs_CZ](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po) | [Lukas Helebrandt](mailto:lukas@aiya.cz) | 85%
<img src="https://joplinapp.org/images/flags/country-4x3/dk.png" width="16px"/> | Dansk (Danmark) | [da_DK](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/da_DK.po) | Mustafa Al-Dailemi (dailemi@hotmail.com)Language-Team: | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/de.png" width="16px"/> | Deutsch (Deutschland) | [de_DE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/de_DE.po) | [Atalanttore](mailto:atalanttore@googlemail.com) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/ee.png" width="16px"/> | Eesti Keel (Eesti) | [et_EE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/et_EE.po) | | 56%
<img src="https://joplinapp.org/images/flags/country-4x3/gb.png" width="16px"/> | English (United Kingdom) | [en_GB](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/en_GB.po) | | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/us.png" width="16px"/> | English (United States of America) | [en_US](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/en_US.po) | | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/es.png" width="16px"/> | Español (España) | [es_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po) | [Mario Campo](mailto:mario.campo@gmail.com) | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/es.png" width="16px"/> | Español (España) | [es_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po) | [Mario Campo](mailto:mario.campo@gmail.com) | 94%
<img src="https://joplinapp.org/images/flags/esperanto.png" width="16px"/> | Esperanto | [eo](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eo.po) | Marton Paulo | 32%
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | mrkaato | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | mrkaato | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/fr.png" width="16px"/> | Français (France) | [fr_FR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fr_FR.po) | Laurent Cozic | 98%
<img src="https://joplinapp.org/images/flags/es/galicia.png" width="16px"/> | Galician (España) | [gl_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po) | [Marcos Lans](mailto:marcoslansgarza@gmail.com) | 38%
<img src="https://joplinapp.org/images/flags/country-4x3/id.png" width="16px"/> | Indonesian (Indonesia) | [id_ID](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po) | [eresytter](mailto:42007357+eresytter@users.noreply.github.com) | 92%
<img src="https://joplinapp.org/images/flags/country-4x3/it.png" width="16px"/> | Italiano (Italia) | [it_IT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po) | [Alberto Pasqualetto](mailto:39854348+albertopasqualetto@users.) | 98%
<img src="https://joplinapp.org/images/flags/country-4x3/hu.png" width="16px"/> | Magyar (Magyarország) | [hu_HU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po) | [Szőke Sándor](mailto:mail@szokesandor.hu) | 87%
<img src="https://joplinapp.org/images/flags/country-4x3/it.png" width="16px"/> | Italiano (Italia) | [it_IT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po) | [Manuel Tassi](mailto:mannivuwiki@gmail.com) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/hu.png" width="16px"/> | Magyar (Magyarország) | [hu_HU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po) | [Szőke Sándor](mailto:mail@szokesandor.hu) | 88%
<img src="https://joplinapp.org/images/flags/country-4x3/be.png" width="16px"/> | Nederlands (België, Belgique, Belgien) | [nl_BE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_BE.po) | | 91%
<img src="https://joplinapp.org/images/flags/country-4x3/nl.png" width="16px"/> | Nederlands (Nederland) | [nl_NL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_NL.po) | [MetBril](mailto:metbril@users.noreply.github.com) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/no.png" width="16px"/> | Norwegian (Norge, Noreg) | [nb_NO](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po) | [Mats Estensen](mailto:code@mxe.no) | 76%
<img src="https://joplinapp.org/images/flags/country-4x3/no.png" width="16px"/> | Norwegian (Norge, Noreg) | [nb_NO](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po) | [Mats Estensen](mailto:code@mxe.no) | 75%
<img src="https://joplinapp.org/images/flags/country-4x3/ir.png" width="16px"/> | Persian | [fa](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fa.po) | [Kourosh Firoozbakht](mailto:kourox@protonmail.com) | 71%
<img src="https://joplinapp.org/images/flags/country-4x3/pl.png" width="16px"/> | Polski (Polska) | [pl_PL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po) | [konhi](mailto:hello.konhi@gmail.com) | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/br.png" width="16px"/> | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po) | [Nicolas Suzuki](mailto:nicolas.suzuki@pm.me) | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/pt.png" width="16px"/> | Português (Portugal) | [pt_PT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po) | [Diogo Caveiro](mailto:dcaveiro@yahoo.com) | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/pl.png" width="16px"/> | Polski (Polska) | [pl_PL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po) | [konhi](mailto:hello.konhi@gmail.com) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/br.png" width="16px"/> | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po) | [Nicolas Suzuki](mailto:nicolas.suzuki@pm.me) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/pt.png" width="16px"/> | Português (Portugal) | [pt_PT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po) | [Diogo Caveiro](mailto:dcaveiro@yahoo.com) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/ro.png" width="16px"/> | Română | [ro](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ro.po) | [Cristi Duluta](mailto:cristi.duluta@gmail.com) | 66%
<img src="https://joplinapp.org/images/flags/country-4x3/si.png" width="16px"/> | Slovenian (Slovenija) | [sl_SI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sl_SI.po) | [Martin Korelič](mailto:martin.korelic@protonmail.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/se.png" width="16px"/> | Svenska | [sv](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sv.po) | [Jonatan Nyberg](mailto:jonatan@autistici.org) | 61%
<img src="https://joplinapp.org/images/flags/country-4x3/th.png" width="16px"/> | Thai (ประเทศไทย) | [th_TH](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/th_TH.po) | | 45%
<img src="https://joplinapp.org/images/flags/country-4x3/vi.png" width="16px"/> | Tiếng Việt | [vi](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/vi.po) | | 73%
<img src="https://joplinapp.org/images/flags/country-4x3/tr.png" width="16px"/> | Türkçe (Türkiye) | [tr_TR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/tr_TR.po) | [Arda Kılıçdağı](mailto:arda@kilicdagi.com) | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/ua.png" width="16px"/> | Ukrainian (Україна) | [uk_UA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po) | [Vyacheslav Andreykiv](mailto:vandreykiv@gmail.com) | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/tr.png" width="16px"/> | Türkçe (Türkiye) | [tr_TR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/tr_TR.po) | [Arda Kılıçdağı](mailto:arda@kilicdagi.com) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/ua.png" width="16px"/> | Ukrainian (Україна) | [uk_UA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po) | [Vyacheslav Andreykiv](mailto:vandreykiv@gmail.com) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/gr.png" width="16px"/> | Ελληνικά (Ελλάδα) | [el_GR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/el_GR.po) | [Harris Arvanitis](mailto:xaris@tuta.io) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/ru.png" width="16px"/> | Русский (Россия) | [ru_RU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po) | [Sergey Segeda](mailto:thesermanarm@gmail.com) | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/ru.png" width="16px"/> | Русский (Россия) | [ru_RU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po) | [Sergey Segeda](mailto:thesermanarm@gmail.com) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/rs.png" width="16px"/> | српски језик (Србија) | [sr_RS](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sr_RS.po) | | 71%
<img src="https://joplinapp.org/images/flags/country-4x3/cn.png" width="16px"/> | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po) | [南宫小骏](mailto:jackytsu@vip.qq.com) | 98%
<img src="https://joplinapp.org/images/flags/country-4x3/tw.png" width="16px"/> | 中文 (繁體) | [zh_TW](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po) | [Yaoze Ye](mailto:yaozeye@yahoo.co.jp) | 92%
<img src="https://joplinapp.org/images/flags/country-4x3/cn.png" width="16px"/> | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po) | [南宫小骏](mailto:jackytsu@vip.qq.com) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/tw.png" width="16px"/> | 中文 (繁體) | [zh_TW](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po) | [Po-Chiang Chao](mailto:BobChao%29%20%28bobchao@gmail.com) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/jp.png" width="16px"/> | 日本語 (日本) | [ja_JP](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ja_JP.po) | [genneko](mailto:genneko217@gmail.com) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/kr.png" width="16px"/> | 한국어 | [ko](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po) | [Ji-Hyeon Gim](mailto:potatogim@potatogim.net) | 98%
<img src="https://joplinapp.org/images/flags/country-4x3/kr.png" width="16px"/> | 한국어 | [ko](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po) | [Ji-Hyeon Gim](mailto:potatogim@potatogim.net) | 99%
<!-- LOCALE-TABLE-AUTO-GENERATED -->
# Contributors
@@ -648,7 +654,7 @@ Thank you to everyone who've contributed to Joplin's source code!
MIT License
Copyright (c) 2016-2020 Laurent Cozic
Copyright (c) 2016-2021 Laurent Cozic
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@@ -86,6 +86,28 @@
</ul>
<p>To view what arguments are supported, you can open any of these files
and look at the <code>execute()</code> command.</p>
<a href="#executing-editor-commands" id="executing-editor-commands" style="color: inherit; text-decoration: none;">
<h2>Executing editor commands</h2>
</a>
<p>There might be a situation where you want to invoke editor commands
without using a <a href="joplincontentscripts.html">contentScript</a>. For this
reason Joplin provides the built in <code>editor.execCommand</code> command.</p>
<p><code>editor.execCommand</code> should work with any core command in both the
<a href="https://codemirror.net/doc/manual.html#execCommand">CodeMirror</a> and
<a href="https://www.tiny.cloud/docs/api/tinymce/tinymce.editorcommands/#execcommand">TinyMCE</a> editors,
as well as most functions calls directly on a CodeMirror editor object (extensions).</p>
<ul>
<li><a href="https://codemirror.net/doc/manual.html#commands">CodeMirror commands</a></li>
<li><a href="https://www.tiny.cloud/docs/advanced/editor-command-identifiers/#coreeditorcommands">TinyMCE core editor commands</a></li>
</ul>
<p><code>editor.execCommand</code> supports adding arguments for the commands.</p>
<pre><code class="language-typescript"><span class="hljs-keyword">await</span> joplin.commands.execute(<span class="hljs-string">&#x27;editor.execCommand&#x27;</span>, {
<span class="hljs-attr">name</span>: <span class="hljs-string">&#x27;madeUpCommand&#x27;</span>, <span class="hljs-comment">// CodeMirror and TinyMCE</span>
<span class="hljs-attr">args</span>: [], <span class="hljs-comment">// CodeMirror and TinyMCE</span>
<span class="hljs-attr">ui</span>: <span class="hljs-literal">false</span>, <span class="hljs-comment">// TinyMCE only</span>
<span class="hljs-attr">value</span>: <span class="hljs-string">&#x27;&#x27;</span>, <span class="hljs-comment">// TinyMCE only</span>
});</code></pre>
<p><a href="https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/support/plugins/codemirror_content_script/src/index.ts">View the example using the CodeMirror editor</a></p>
</div>
</section>
<!--

View File

@@ -149,10 +149,8 @@
<p>Currently the supported context variables aren&#39;t documented, but you can
find the list below:</p>
<ul>
<li>[Global When
Clauses](<a href="https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts">https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts</a>).</li>
<li>[Desktop app When
Clauses](<a href="https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts">https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts</a>).</li>
<li><a href="https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts">Global When Clauses</a></li>
<li><a href="https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts">Desktop app When Clauses</a></li>
</ul>
<p>Note: Commands are enabled by default unless you use this property.</p>
</div>

View File

@@ -405,6 +405,12 @@ https://github.com/laurent22/joplin/blob/dev/readme/changelog.md
<p><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&amp;business=E8JMYD2LQ8MMA&amp;lc=GB&amp;item_name=Joplin+Development&amp;currency_code=EUR&amp;bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted"><img src="https://joplinapp.org/images/badges/Donate-PayPal-green.svg" alt="Donate using PayPal"></a> <a href="https://github.com/sponsors/laurent22/"><img src="https://joplinapp.org/images/badges/GitHub-Badge.svg" alt="Sponsor on GitHub"></a> <a href="https://www.patreon.com/joplin"><img src="https://joplinapp.org/images/badges/Patreon-Badge.svg" alt="Become a patron"></a> <a href="https://joplinapp.org/donate/#donations"><img src="https://joplinapp.org/images/badges/Donate-IBAN.svg" alt="Donate using IBAN"></a></p>
<hr>
<h1>Joplin changelog<a name="joplin-changelog" href="#joplin-changelog" class="heading-anchor">🔗</a></h1>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/v2.0.4">v2.0.4</a> (Pre-release) - 2021-06-02T12:54:17Z<a name="v2-0-4-https-github-com-laurent22-joplin-releases-tag-v2-0-4-pre-release-2021-06-02t12-54-17z" href="#v2-0-4-https-github-com-laurent22-joplin-releases-tag-v2-0-4-pre-release-2021-06-02t12-54-17z" class="heading-anchor">🔗</a></h2>
<ul>
<li>Improved: Download plugins from GitHub release (8f6a475)</li>
<li>Fixed: Count tags based on showCompletedTodos setting (<a href="https://github.com/laurent22/joplin/issues/4957">#4957</a>) (<a href="https://github.com/laurent22/joplin/issues/4411">#4411</a> by <a href="https://github.com/JackGruber">@JackGruber</a>)</li>
<li>Fixed: Fixes panels overflowing window (<a href="https://github.com/laurent22/joplin/issues/4991">#4991</a>) (<a href="https://github.com/laurent22/joplin/issues/4864">#4864</a> by <a href="https://github.com/mablin7">@mablin7</a>)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/v2.0.2">v2.0.2</a> (Pre-release) - 2021-05-21T18:07:48Z<a name="v2-0-2-https-github-com-laurent22-joplin-releases-tag-v2-0-2-pre-release-2021-05-21t18-07-48z" href="#v2-0-2-https-github-com-laurent22-joplin-releases-tag-v2-0-2-pre-release-2021-05-21t18-07-48z" class="heading-anchor">🔗</a></h2>
<ul>
<li>New: Add Share Notebook menu item (6f2f241)</li>

View File

@@ -405,6 +405,40 @@ https://github.com/laurent22/joplin/blob/dev/readme/changelog_server.md
<p><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&amp;business=E8JMYD2LQ8MMA&amp;lc=GB&amp;item_name=Joplin+Development&amp;currency_code=EUR&amp;bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted"><img src="https://joplinapp.org/images/badges/Donate-PayPal-green.svg" alt="Donate using PayPal"></a> <a href="https://github.com/sponsors/laurent22/"><img src="https://joplinapp.org/images/badges/GitHub-Badge.svg" alt="Sponsor on GitHub"></a> <a href="https://www.patreon.com/joplin"><img src="https://joplinapp.org/images/badges/Patreon-Badge.svg" alt="Become a patron"></a> <a href="https://joplinapp.org/donate/#donations"><img src="https://joplinapp.org/images/badges/Donate-IBAN.svg" alt="Donate using IBAN"></a></p>
<hr>
<h1>Joplin Server Changelog<a name="joplin-server-changelog" href="#joplin-server-changelog" class="heading-anchor">🔗</a></h1>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/server-v2.0.5">server-v2.0.5</a> (Pre-release) - 2021-06-02T08:14:47Z<a name="server-v2-0-5-https-github-com-laurent22-joplin-releases-tag-server-v2-0-5-pre-release-2021-06-02t08-14-47z" href="#server-v2-0-5-https-github-com-laurent22-joplin-releases-tag-server-v2-0-5-pre-release-2021-06-02t08-14-47z" class="heading-anchor">🔗</a></h2>
<ul>
<li>New: Add version number on website (0ef7e98)</li>
<li>New: Added signup pages (41ed66d)</li>
<li>Improved: Allow disabling item upload for a user (f8a26cf)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/server-v2.0.4">server-v2.0.4</a> (Pre-release) - 2021-05-25T18:33:11Z<a name="server-v2-0-4-https-github-com-laurent22-joplin-releases-tag-server-v2-0-4-pre-release-2021-05-25t18-33-11z" href="#server-v2-0-4-https-github-com-laurent22-joplin-releases-tag-server-v2-0-4-pre-release-2021-05-25t18-33-11z" class="heading-anchor">🔗</a></h2>
<ul>
<li>Fixed: Fixed Item and Log page when using Postgres (ee0f237)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/server-v2.0.3">server-v2.0.3</a> (Pre-release) - 2021-05-25T18:08:46Z<a name="server-v2-0-3-https-github-com-laurent22-joplin-releases-tag-server-v2-0-3-pre-release-2021-05-25t18-08-46z" href="#server-v2-0-3-https-github-com-laurent22-joplin-releases-tag-server-v2-0-3-pre-release-2021-05-25t18-08-46z" class="heading-anchor">🔗</a></h2>
<ul>
<li>Fixed: Fixed handling of request origin (12a6634)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/server-v2.0.2">server-v2.0.2</a> (Pre-release) - 2021-05-25T19:15:50Z<a name="server-v2-0-2-https-github-com-laurent22-joplin-releases-tag-server-v2-0-2-pre-release-2021-05-25t19-15-50z" href="#server-v2-0-2-https-github-com-laurent22-joplin-releases-tag-server-v2-0-2-pre-release-2021-05-25t19-15-50z" class="heading-anchor">🔗</a></h2>
<ul>
<li>New: Add mailer service (ed8ee67)</li>
<li>New: Add support for item size limit (6afde54)</li>
<li>New: Added API end points to manage users (77b284f)</li>
<li>Improved: Allow enabling or disabling the sharing feature per user (daaaa13)</li>
<li>Improved: Allow setting the path to the SQLite database using SQLITE_DATABASE env variable (68e79f1)</li>
<li>Improved: Allow using a different domain for API, main website and user content (83cef7a)</li>
<li>Improved: Generate only one share link per note (e156ee1)</li>
<li>Improved: Go back to home page when there is an error and user is logged in (a24b009)</li>
<li>Improved: Improved Items table and added item size to it (7f05420)</li>
<li>Improved: Improved log table too and made it sortable (ec7f0f4)</li>
<li>Improved: Make it more difficult to delete all data (b01aa7e)</li>
<li>Improved: Redirect to correct page when trying to access the root (51051e0)</li>
<li>Improved: Use external directory to store Postgres data in Docker-compose config (71a7fc0)</li>
<li>Fixed: Fixed /items page when using Postgres (2d0580f)</li>
<li>Fixed: Fixed bug when unsharing a notebook that has no recipients (6ddb69e)</li>
<li>Fixed: Fixed deleting a note that has been shared (489995d)</li>
<li>Fixed: Make sure temp files are deleted after upload is done (#4540)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/server-v2.0.1">server-v2.0.1</a> (Pre-release) - 2021-05-14T13:55:45Z<a name="server-v2-0-1-https-github-com-laurent22-joplin-releases-tag-server-v2-0-1-pre-release-2021-05-14t13-55-45z" href="#server-v2-0-1-https-github-com-laurent22-joplin-releases-tag-server-v2-0-1-pre-release-2021-05-14t13-55-45z" class="heading-anchor">🔗</a></h2>
<ul>
<li>New: Add support for sharing notes via a link (ccbc329)</li>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -99,17 +99,17 @@
"sync.9.path": {
"type": "string",
"default": "",
"description": "Joplin Server URL. Attention: If you change this location, make sure you copy all your content to it before syncing, otherwise all files will be removed! See the FAQ for more details: https://joplinapp.org/faq/"
"description": "Joplin Cloud URL. Attention: If you change this location, make sure you copy all your content to it before syncing, otherwise all files will be removed! See the FAQ for more details: https://joplinapp.org/faq/"
},
"sync.9.username": {
"type": "string",
"default": "",
"description": "Joplin Server email"
"description": "Joplin Cloud email"
},
"sync.9.password": {
"type": "string",
"default": "",
"description": "Joplin Server password",
"description": "Joplin Cloud password",
"$comment": "private"
},
"sync.5.syncTargets": {

View File

@@ -415,15 +415,15 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tbody>
<tr>
<td>Total Windows downloads</td>
<td>1,425,567</td>
<td>1,444,540</td>
</tr>
<tr>
<td>Total macOs downloads</td>
<td>554,909</td>
<td>561,465</td>
</tr>
<tr>
<td>Total Linux downloads</td>
<td>463,554</td>
<td>473,228</td>
</tr>
<tr>
<td>Windows %</td>
@@ -453,92 +453,100 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
</thead>
<tbody>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v2.0.4">v2.0.4</a> (p)</td>
<td>2021-06-02T12:54:17Z</td>
<td>898</td>
<td>267</td>
<td>242</td>
<td>1,407</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v2.0.2">v2.0.2</a> (p)</td>
<td>2021-05-21T18:07:48Z</td>
<td>594</td>
<td>179</td>
<td>448</td>
<td>1,221</td>
<td>1,953</td>
<td>470</td>
<td>1,554</td>
<td>3,977</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v2.0.1">v2.0.1</a> (p)</td>
<td>2021-05-15T13:22:58Z</td>
<td>770</td>
<td>243</td>
<td>984</td>
<td>1,997</td>
<td>784</td>
<td>245</td>
<td>994</td>
<td>2,023</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.8.5">v1.8.5</a></td>
<td>2021-05-10T11:58:14Z</td>
<td>11,870</td>
<td>6,670</td>
<td>5,753</td>
<td>24,293</td>
<td>27,272</td>
<td>12,591</td>
<td>13,983</td>
<td>53,846</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.8.4">v1.8.4</a> (p)</td>
<td>2021-05-09T18:05:05Z</td>
<td>623</td>
<td>656</td>
<td>120</td>
<td>433</td>
<td>1,176</td>
<td>1,209</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.8.3">v1.8.3</a> (p)</td>
<td>2021-05-04T10:38:16Z</td>
<td>1,049</td>
<td>290</td>
<td>1,280</td>
<td>293</td>
<td>912</td>
<td>2,251</td>
<td>2,485</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.8.2">v1.8.2</a> (p)</td>
<td>2021-04-25T10:50:51Z</td>
<td>1,445</td>
<td>1,473</td>
<td>421</td>
<td>1,261</td>
<td>3,127</td>
<td>3,155</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.8.1">v1.8.1</a> (p)</td>
<td>2021-03-29T10:46:41Z</td>
<td>3,003</td>
<td>3,025</td>
<td>805</td>
<td>2,418</td>
<td>6,226</td>
<td>2,419</td>
<td>6,249</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.7.11">v1.7.11</a></td>
<td>2021-02-03T12:50:01Z</td>
<td>113,794</td>
<td>42,526</td>
<td>64,040</td>
<td>220,360</td>
<td>113,946</td>
<td>42,556</td>
<td>64,079</td>
<td>220,581</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.7.10">v1.7.10</a></td>
<td>2021-01-30T13:25:29Z</td>
<td>13,825</td>
<td>13,830</td>
<td>4,831</td>
<td>4,425</td>
<td>23,081</td>
<td>4,429</td>
<td>23,090</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.7.9">v1.7.9</a> (p)</td>
<td>2021-01-28T09:50:21Z</td>
<td>480</td>
<td>481</td>
<td>123</td>
<td>483</td>
<td>1,086</td>
<td>1,087</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.7.6">v1.7.6</a> (p)</td>
<td>2021-01-27T10:36:05Z</td>
<td>283</td>
<td>284</td>
<td>82</td>
<td>277</td>
<td>642</td>
<td>643</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.7.5">v1.7.5</a> (p)</td>
@@ -559,10 +567,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.6.8">v1.6.8</a></td>
<td>2021-01-20T18:11:34Z</td>
<td>18,064</td>
<td>7,662</td>
<td>7,578</td>
<td>33,304</td>
<td>18,148</td>
<td>7,665</td>
<td>7,581</td>
<td>33,394</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.7.3">v1.7.3</a> (p)</td>
@@ -575,50 +583,50 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.6.7">v1.6.7</a></td>
<td>2021-01-11T23:20:33Z</td>
<td>10,375</td>
<td>4,617</td>
<td>10,418</td>
<td>4,619</td>
<td>4,531</td>
<td>19,523</td>
<td>19,568</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.6.6">v1.6.6</a></td>
<td>2021-01-09T16:15:31Z</td>
<td>12,359</td>
<td>12,362</td>
<td>3,405</td>
<td>4,776</td>
<td>20,540</td>
<td>20,543</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.6.5">v1.6.5</a> (p)</td>
<td>2021-01-09T01:24:32Z</td>
<td>553</td>
<td>580</td>
<td>57</td>
<td>300</td>
<td>910</td>
<td>937</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.6.4">v1.6.4</a> (p)</td>
<td>2021-01-07T19:11:32Z</td>
<td>381</td>
<td>72</td>
<td>73</td>
<td>197</td>
<td>650</td>
<td>651</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.6.2">v1.6.2</a> (p)</td>
<td>2021-01-04T22:34:55Z</td>
<td>664</td>
<td>665</td>
<td>221</td>
<td>577</td>
<td>1,462</td>
<td>1,463</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.5.14">v1.5.14</a></td>
<td>2020-12-30T01:48:46Z</td>
<td>10,847</td>
<td>5,191</td>
<td>10,889</td>
<td>5,192</td>
<td>5,512</td>
<td>21,550</td>
<td>21,593</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.6.1">v1.6.1</a> (p)</td>
@@ -639,18 +647,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.5.12">v1.5.12</a></td>
<td>2020-12-28T15:14:08Z</td>
<td>2,374</td>
<td>2,378</td>
<td>1,762</td>
<td>911</td>
<td>5,047</td>
<td>5,051</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.5.11">v1.5.11</a></td>
<td>2020-12-27T19:54:07Z</td>
<td>13,999</td>
<td>14,009</td>
<td>4,605</td>
<td>4,253</td>
<td>22,857</td>
<td>4,254</td>
<td>22,868</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.5.10">v1.5.10</a> (p)</td>
@@ -664,9 +672,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.5.9">v1.5.9</a> (p)</td>
<td>2020-12-23T18:01:08Z</td>
<td>321</td>
<td>367</td>
<td>399</td>
<td>1,087</td>
<td>368</td>
<td>400</td>
<td>1,089</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.5.8">v1.5.8</a> (p)</td>
@@ -695,26 +703,26 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.4.19">v1.4.19</a></td>
<td>2020-12-01T11:11:16Z</td>
<td>25,492</td>
<td>13,350</td>
<td>11,610</td>
<td>50,452</td>
<td>25,530</td>
<td>13,358</td>
<td>11,615</td>
<td>50,503</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.4.18">v1.4.18</a></td>
<td>2020-11-28T12:21:41Z</td>
<td>11,082</td>
<td>3,870</td>
<td>3,076</td>
<td>18,028</td>
<td>11,087</td>
<td>3,871</td>
<td>3,081</td>
<td>18,039</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.4.16">v1.4.16</a></td>
<td>2020-11-27T19:40:16Z</td>
<td>1,452</td>
<td>1,457</td>
<td>822</td>
<td>584</td>
<td>2,858</td>
<td>2,863</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.4.15">v1.4.15</a></td>
@@ -727,18 +735,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.4.12">v1.4.12</a></td>
<td>2020-11-23T18:58:07Z</td>
<td>2,983</td>
<td>2,994</td>
<td>1,316</td>
<td>1,287</td>
<td>5,586</td>
<td>5,597</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.4.11">v1.4.11</a> (p)</td>
<td>2020-11-19T23:06:51Z</td>
<td>946</td>
<td>147</td>
<td>974</td>
<td>148</td>
<td>574</td>
<td>1,667</td>
<td>1,696</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.4.10">v1.4.10</a> (p)</td>
@@ -751,10 +759,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.4.9">v1.4.9</a> (p)</td>
<td>2020-11-11T14:23:17Z</td>
<td>497</td>
<td>499</td>
<td>133</td>
<td>393</td>
<td>1,023</td>
<td>1,025</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.4.7">v1.4.7</a> (p)</td>
@@ -767,10 +775,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.3.18">v1.3.18</a></td>
<td>2020-11-06T12:07:02Z</td>
<td>30,609</td>
<td>30,668</td>
<td>11,316</td>
<td>10,495</td>
<td>52,420</td>
<td>52,479</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.3.17">v1.3.17</a> (p)</td>
@@ -783,18 +791,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.4.6">v1.4.6</a> (p)</td>
<td>2020-11-05T22:44:12Z</td>
<td>339</td>
<td>341</td>
<td>86</td>
<td>45</td>
<td>470</td>
<td>472</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.3.15">v1.3.15</a></td>
<td>2020-11-04T12:22:50Z</td>
<td>2,221</td>
<td>2,223</td>
<td>1,290</td>
<td>836</td>
<td>4,347</td>
<td>4,349</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.3.11">v1.3.11</a> (p)</td>
@@ -807,10 +815,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.3.10">v1.3.10</a> (p)</td>
<td>2020-10-29T13:27:14Z</td>
<td>368</td>
<td>369</td>
<td>107</td>
<td>307</td>
<td>782</td>
<td>783</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.3.9">v1.3.9</a> (p)</td>
@@ -848,9 +856,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.3.3">v1.3.3</a> (p)</td>
<td>2020-10-17T10:56:57Z</td>
<td>113</td>
<td>36</td>
<td>38</td>
<td>25</td>
<td>174</td>
<td>176</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.3.2">v1.3.2</a> (p)</td>
@@ -864,17 +872,17 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.3.1">v1.3.1</a> (p)</td>
<td>2020-10-11T15:10:18Z</td>
<td>77</td>
<td>45</td>
<td>35</td>
<td>157</td>
<td>46</td>
<td>36</td>
<td>159</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.2.6">v1.2.6</a></td>
<td>2020-10-09T13:56:59Z</td>
<td>44,164</td>
<td>44,215</td>
<td>17,713</td>
<td>14,024</td>
<td>75,901</td>
<td>14,026</td>
<td>75,954</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.2.4">v1.2.4</a> (p)</td>
@@ -895,18 +903,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.2.2">v1.2.2</a> (p)</td>
<td>2020-09-22T20:31:55Z</td>
<td>777</td>
<td>779</td>
<td>199</td>
<td>631</td>
<td>1,607</td>
<td>1,609</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.1.4">v1.1.4</a></td>
<td>2020-09-21T11:20:09Z</td>
<td>27,572</td>
<td>13,489</td>
<td>27,592</td>
<td>13,490</td>
<td>7,740</td>
<td>48,801</td>
<td>48,822</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.1.3">v1.1.3</a> (p)</td>
@@ -927,42 +935,42 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.1.1">v1.1.1</a> (p)</td>
<td>2020-09-11T23:32:47Z</td>
<td>519</td>
<td>521</td>
<td>195</td>
<td>342</td>
<td>1,056</td>
<td>1,058</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.245">v1.0.245</a></td>
<td>2020-09-09T12:56:10Z</td>
<td>21,148</td>
<td>21,177</td>
<td>9,999</td>
<td>5,634</td>
<td>36,781</td>
<td>36,810</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.242">v1.0.242</a></td>
<td>2020-09-04T22:00:34Z</td>
<td>12,439</td>
<td>12,446</td>
<td>6,418</td>
<td>3,015</td>
<td>21,872</td>
<td>21,879</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.241">v1.0.241</a></td>
<td>2020-09-04T18:06:00Z</td>
<td>23,628</td>
<td>5,748</td>
<td>4,994</td>
<td>34,370</td>
<td>23,665</td>
<td>5,749</td>
<td>4,995</td>
<td>34,409</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.239">v1.0.239</a> (p)</td>
<td>2020-09-01T21:56:36Z</td>
<td>599</td>
<td>601</td>
<td>226</td>
<td>400</td>
<td>1,225</td>
<td>1,227</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.237">v1.0.237</a> (p)</td>
@@ -983,26 +991,26 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.235">v1.0.235</a> (p)</td>
<td>2020-08-18T22:08:01Z</td>
<td>1,671</td>
<td>1,673</td>
<td>489</td>
<td>920</td>
<td>3,080</td>
<td>3,082</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.234">v1.0.234</a> (p)</td>
<td>2020-08-17T23:13:02Z</td>
<td>536</td>
<td>538</td>
<td>125</td>
<td>100</td>
<td>761</td>
<td>763</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.233">v1.0.233</a></td>
<td>2020-08-01T14:51:15Z</td>
<td>43,098</td>
<td>43,153</td>
<td>18,188</td>
<td>12,358</td>
<td>73,644</td>
<td>73,699</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.232">v1.0.232</a> (p)</td>
@@ -1015,10 +1023,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.227">v1.0.227</a></td>
<td>2020-07-07T20:44:54Z</td>
<td>40,384</td>
<td>15,273</td>
<td>9,627</td>
<td>65,284</td>
<td>40,414</td>
<td>15,274</td>
<td>9,629</td>
<td>65,317</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.226">v1.0.226</a> (p)</td>
@@ -1031,10 +1039,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.224">v1.0.224</a></td>
<td>2020-06-20T22:26:08Z</td>
<td>24,774</td>
<td>24,788</td>
<td>11,005</td>
<td>6,006</td>
<td>41,785</td>
<td>41,799</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.223">v1.0.223</a> (p)</td>
@@ -1055,18 +1063,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.220">v1.0.220</a></td>
<td>2020-06-13T18:26:22Z</td>
<td>31,712</td>
<td>31,734</td>
<td>9,916</td>
<td>6,411</td>
<td>48,039</td>
<td>48,061</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.218">v1.0.218</a></td>
<td>2020-06-07T10:43:34Z</td>
<td>14,535</td>
<td>14,536</td>
<td>6,968</td>
<td>2,954</td>
<td>24,457</td>
<td>24,458</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.217">v1.0.217</a> (p)</td>
@@ -1079,18 +1087,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.216">v1.0.216</a></td>
<td>2020-05-24T14:21:01Z</td>
<td>37,277</td>
<td>14,268</td>
<td>37,327</td>
<td>14,269</td>
<td>10,177</td>
<td>61,722</td>
<td>61,773</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.214">v1.0.214</a> (p)</td>
<td>2020-05-21T17:15:15Z</td>
<td>6,529</td>
<td>6,545</td>
<td>3,466</td>
<td>760</td>
<td>10,755</td>
<td>10,771</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.212">v1.0.212</a> (p)</td>
@@ -1119,18 +1127,18 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.207">v1.0.207</a> (p)</td>
<td>2020-05-10T16:37:35Z</td>
<td>1,187</td>
<td>1,188</td>
<td>263</td>
<td>1,016</td>
<td>2,466</td>
<td>2,467</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.201">v1.0.201</a></td>
<td>2020-04-15T22:55:13Z</td>
<td>53,311</td>
<td>53,324</td>
<td>20,043</td>
<td>18,180</td>
<td>91,534</td>
<td>91,547</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.200">v1.0.200</a></td>
@@ -1143,98 +1151,98 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.199">v1.0.199</a></td>
<td>2020-04-10T18:41:58Z</td>
<td>19,339</td>
<td>19,347</td>
<td>5,884</td>
<td>3,788</td>
<td>29,011</td>
<td>29,019</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.197">v1.0.197</a></td>
<td>2020-03-30T17:21:22Z</td>
<td>22,280</td>
<td>22,290</td>
<td>9,540</td>
<td>5,726</td>
<td>37,546</td>
<td>5,734</td>
<td>37,564</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.195">v1.0.195</a></td>
<td>2020-03-22T19:56:12Z</td>
<td>18,890</td>
<td>7,948</td>
<td>18,892</td>
<td>7,949</td>
<td>4,506</td>
<td>31,344</td>
<td>31,347</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.194">v1.0.194</a> (p)</td>
<td>2020-03-14T00:00:32Z</td>
<td>1,285</td>
<td>1,375</td>
<td>511</td>
<td>3,171</td>
<td>1,377</td>
<td>513</td>
<td>3,175</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.193">v1.0.193</a></td>
<td>2020-03-08T08:58:53Z</td>
<td>28,641</td>
<td>28,642</td>
<td>10,907</td>
<td>7,392</td>
<td>46,940</td>
<td>7,393</td>
<td>46,942</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.192">v1.0.192</a> (p)</td>
<td>2020-03-06T23:27:52Z</td>
<td>472</td>
<td>473</td>
<td>122</td>
<td>89</td>
<td>683</td>
<td>684</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.190">v1.0.190</a> (p)</td>
<td>2020-03-06T01:22:22Z</td>
<td>373</td>
<td>374</td>
<td>90</td>
<td>85</td>
<td>548</td>
<td>549</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.189">v1.0.189</a> (p)</td>
<td>2020-03-04T17:27:15Z</td>
<td>342</td>
<td>343</td>
<td>96</td>
<td>90</td>
<td>528</td>
<td>529</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.187">v1.0.187</a> (p)</td>
<td>2020-03-01T12:31:06Z</td>
<td>919</td>
<td>920</td>
<td>230</td>
<td>263</td>
<td>1,412</td>
<td>1,413</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.179">v1.0.179</a></td>
<td>2020-01-24T22:42:41Z</td>
<td>71,023</td>
<td>28,545</td>
<td>22,534</td>
<td>122,102</td>
<td>71,040</td>
<td>28,550</td>
<td>22,535</td>
<td>122,125</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.178">v1.0.178</a></td>
<td>2020-01-20T19:06:45Z</td>
<td>17,539</td>
<td>17,540</td>
<td>5,962</td>
<td>2,584</td>
<td>26,085</td>
<td>26,086</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.177">v1.0.177</a> (p)</td>
<td>2019-12-30T14:40:40Z</td>
<td>1,943</td>
<td>1,944</td>
<td>438</td>
<td>678</td>
<td>3,059</td>
<td>679</td>
<td>3,061</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.176">v1.0.176</a> (p)</td>
@@ -1247,42 +1255,42 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.175">v1.0.175</a></td>
<td>2019-12-08T11:48:47Z</td>
<td>72,519</td>
<td>16,905</td>
<td>72,538</td>
<td>16,906</td>
<td>16,509</td>
<td>105,933</td>
<td>105,953</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.174">v1.0.174</a></td>
<td>2019-11-12T18:20:58Z</td>
<td>30,401</td>
<td>30,407</td>
<td>11,722</td>
<td>8,221</td>
<td>50,344</td>
<td>50,350</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.173">v1.0.173</a></td>
<td>2019-11-11T08:33:35Z</td>
<td>5,072</td>
<td>5,074</td>
<td>2,077</td>
<td>743</td>
<td>7,892</td>
<td>7,894</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.170">v1.0.170</a></td>
<td>2019-10-13T22:13:04Z</td>
<td>27,413</td>
<td>27,424</td>
<td>8,752</td>
<td>7,675</td>
<td>43,840</td>
<td>43,851</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.169">v1.0.169</a></td>
<td>2019-09-27T18:35:13Z</td>
<td>17,097</td>
<td>17,098</td>
<td>5,921</td>
<td>3,754</td>
<td>26,772</td>
<td>26,773</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.168">v1.0.168</a></td>
@@ -1295,10 +1303,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.167">v1.0.167</a></td>
<td>2019-09-10T08:48:37Z</td>
<td>16,790</td>
<td>16,791</td>
<td>5,704</td>
<td>3,703</td>
<td>26,197</td>
<td>26,198</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.166">v1.0.166</a></td>
@@ -1311,34 +1319,34 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.165">v1.0.165</a></td>
<td>2019-08-14T21:46:29Z</td>
<td>18,898</td>
<td>18,903</td>
<td>6,972</td>
<td>5,462</td>
<td>31,332</td>
<td>31,337</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.161">v1.0.161</a></td>
<td>2019-07-13T18:30:00Z</td>
<td>19,285</td>
<td>19,287</td>
<td>6,352</td>
<td>4,136</td>
<td>29,773</td>
<td>29,775</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.160">v1.0.160</a></td>
<td>2019-06-15T00:21:40Z</td>
<td>30,531</td>
<td>7,745</td>
<td>30,535</td>
<td>7,746</td>
<td>8,101</td>
<td>46,377</td>
<td>46,382</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.159">v1.0.159</a></td>
<td>2019-06-08T00:00:19Z</td>
<td>5,194</td>
<td>2,178</td>
<td>1,112</td>
<td>8,484</td>
<td>1,113</td>
<td>8,485</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.158">v1.0.158</a></td>
@@ -1425,8 +1433,8 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<td>2019-03-10T20:59:58Z</td>
<td>13,629</td>
<td>4,171</td>
<td>3,223</td>
<td>21,023</td>
<td>3,227</td>
<td>21,027</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.139">v1.0.139</a> (p)</td>
@@ -1440,9 +1448,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.138">v1.0.138</a> (p)</td>
<td>2019-03-03T17:23:00Z</td>
<td>150</td>
<td>86</td>
<td>87</td>
<td>84</td>
<td>320</td>
<td>321</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.137">v1.0.137</a> (p)</td>
@@ -1455,10 +1463,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.135">v1.0.135</a></td>
<td>2019-02-27T23:36:57Z</td>
<td>12,514</td>
<td>12,515</td>
<td>3,958</td>
<td>4,077</td>
<td>20,549</td>
<td>20,550</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.134">v1.0.134</a></td>
@@ -1472,17 +1480,17 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.132">v1.0.132</a></td>
<td>2019-02-26T23:02:05Z</td>
<td>1,088</td>
<td>451</td>
<td>452</td>
<td>95</td>
<td>1,634</td>
<td>1,635</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.127">v1.0.127</a></td>
<td>2019-02-14T23:12:48Z</td>
<td>9,785</td>
<td>3,171</td>
<td>9,786</td>
<td>3,172</td>
<td>2,929</td>
<td>15,885</td>
<td>15,887</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.126">v1.0.126</a> (p)</td>
@@ -1504,9 +1512,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.120">v1.0.120</a></td>
<td>2019-01-10T21:42:53Z</td>
<td>15,605</td>
<td>5,201</td>
<td>5,202</td>
<td>6,517</td>
<td>27,323</td>
<td>27,324</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.119">v1.0.119</a></td>
@@ -1536,9 +1544,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.116">v1.0.116</a></td>
<td>2018-11-20T19:09:24Z</td>
<td>3,474</td>
<td>1,121</td>
<td>1,122</td>
<td>714</td>
<td>5,309</td>
<td>5,310</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.115">v1.0.115</a></td>
@@ -1559,10 +1567,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.111">v1.0.111</a></td>
<td>2018-09-30T20:15:09Z</td>
<td>12,041</td>
<td>3,307</td>
<td>3,668</td>
<td>19,016</td>
<td>12,042</td>
<td>3,308</td>
<td>3,669</td>
<td>19,019</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.110">v1.0.110</a></td>
@@ -1688,9 +1696,9 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.93">v1.0.93</a></td>
<td>2018-05-14T11:36:01Z</td>
<td>1,791</td>
<td>1,157</td>
<td>1,158</td>
<td>759</td>
<td>3,707</td>
<td>3,708</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.91">v1.0.91</a></td>
@@ -1719,10 +1727,10 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.83">v1.0.83</a></td>
<td>2018-04-04T19:43:58Z</td>
<td>4,886</td>
<td>4,892</td>
<td>2,532</td>
<td>2,658</td>
<td>10,076</td>
<td>10,082</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v1.0.82">v1.0.82</a></td>
@@ -2001,8 +2009,8 @@ https://github.com/laurent22/joplin/blob/dev/readme/stats.md
<td>2017-11-24T14:27:49Z</td>
<td>150</td>
<td>696</td>
<td>6,461</td>
<td>7,307</td>
<td>6,463</td>
<td>7,309</td>
</tr>
<tr>
<td><a href="https://github.com/laurent22/joplin/releases/tag/v0.10.23">v0.10.23</a></td>

View File

@@ -1,6 +1,6 @@
{
"name": "joplin",
"version": "1.8.1",
"version": "2.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -1806,80 +1806,6 @@
}
}
},
"clean-html": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/clean-html/-/clean-html-1.5.0.tgz",
"integrity": "sha512-eDu0vN44ZBvoEU0oRIKwWPIccGWXtdnUNmKJuTukZ1de00Uoqavb5pfIMKiC7/r+knQ5RbvAjGuVZiN3JwJL4Q==",
"requires": {
"htmlparser2": "^3.8.2",
"minimist": "^1.1.1"
},
"dependencies": {
"domelementtype": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="
},
"domhandler": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
"requires": {
"domelementtype": "1"
}
},
"domutils": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
"integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
"requires": {
"dom-serializer": "0",
"domelementtype": "1"
}
},
"htmlparser2": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
"requires": {
"domelementtype": "^1.3.1",
"domhandler": "^2.3.0",
"domutils": "^1.5.1",
"entities": "^1.1.1",
"inherits": "^2.0.1",
"readable-stream": "^3.1.1"
}
},
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
},
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
},
"safe-buffer": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
"integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg=="
},
"string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"requires": {
"safe-buffer": "~5.2.0"
}
}
}
},
"cliui": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz",
@@ -2403,27 +2329,6 @@
"integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==",
"dev": true
},
"dom-serializer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
"integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
"requires": {
"domelementtype": "^2.0.1",
"entities": "^2.0.0"
},
"dependencies": {
"entities": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz",
"integrity": "sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw=="
}
}
},
"domelementtype": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz",
"integrity": "sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ=="
},
"domexception": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz",
@@ -2545,11 +2450,6 @@
"once": "^1.4.0"
}
},
"entities": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz",
"integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA="
},
"error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -4666,7 +4566,8 @@
},
"y18n": {
"version": "4.0.0",
"resolved": "",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
"integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==",
"dev": true
},
"yargs": {

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

@@ -526,6 +526,14 @@ function useMenu(props: Props) {
click: () => { bridge().electronApp().hide(); },
} : noItem,
shim.isMac() ? {
role: 'hideothers',
} : noItem,
shim.isMac() ? {
role: 'unhide',
} : noItem,
{
type: 'separator',
},

View File

@@ -224,10 +224,12 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
textHeading: () => addListItem('## ', ''),
textHorizontalRule: () => addListItem('* * *'),
'editor.execCommand': (value: CommandValue) => {
if (editorRef.current[value.name]) {
if (!('args' in value)) value.args = [];
if (!('args' in value)) value.args = [];
if (editorRef.current[value.name]) {
editorRef.current[value.name](...value.args);
} else if (editorRef.current.commandExists(value.name)) {
editorRef.current.execCommand(value.name);
} else {
reg.logger().warn('CodeMirror execCommand: unsupported command: ', value.name);
}

View File

@@ -20,6 +20,7 @@ import useEditorSearch from './utils/useEditorSearch';
import useJoplinMode from './utils/useJoplinMode';
import useKeymap from './utils/useKeymap';
import useExternalPlugins from './utils/useExternalPlugins';
import useJoplinCommands from './utils/useJoplinCommands';
import 'codemirror/keymap/emacs';
import 'codemirror/keymap/vim';
@@ -107,6 +108,7 @@ function Editor(props: EditorProps, ref: any) {
useJoplinMode(CodeMirror);
const pluginOptions: any = useExternalPlugins(CodeMirror, props.plugins);
useKeymap(CodeMirror);
useJoplinCommands(CodeMirror);
useImperativeHandle(ref, () => {
return editor;

View File

@@ -2,19 +2,76 @@ import { modifyListLines } from './useCursorUtils';
describe('useCursorUtils', () => {
const listWithDashes = `- item1
- item2
- item3`;
const listWithDashes = [
'- item1',
'- item2',
'- item3',
];
const listNoDashes = `item1
item2
item3`;
const listWithNoPrefixes = [
'item1',
'item2',
'item3',
];
test('should remove "- " from beggining of each line of input string', () => {
expect(JSON.stringify(modifyListLines(listWithDashes.split('\n'), 0, '- '))).toBe(JSON.stringify(listNoDashes.split('\n')));
const listWithNumbers = [
'1. item1',
'2. item2',
'3. item3',
];
const listWithOnes = [
'1. item1',
'1. item2',
'1. item3',
];
const listWithSomeNumbers = [
'1. item1',
'item2',
'2. item3',
];
const numberedListWithEmptyLines = [
'1. item1',
'2. item2',
'3. ' ,
'4. item3',
];
const noPrefixListWithEmptyLines = [
'item1',
'item2',
'' ,
'item3',
];
test('should remove "- " from beginning of each line of input string', () => {
expect(modifyListLines([...listWithDashes], NaN, '- ')).toStrictEqual(listWithNoPrefixes);
});
test('should add "- " at the beggining of each line of the input string', () => {
expect(JSON.stringify(modifyListLines(listNoDashes.split('\n'), 0, '- '))).toBe(JSON.stringify(listWithDashes.split('\n')));
test('should add "- " at the beginning of each line of the input string', () => {
expect(modifyListLines([...listWithNoPrefixes], NaN, '- ')).toStrictEqual(listWithDashes);
});
test('should remove "n. " at the beginning of each line of the input string', () => {
expect(modifyListLines([...listWithNumbers], 4, '1. ')).toStrictEqual(listWithNoPrefixes);
});
test('should add "n. " at the beginning of each line of the input string', () => {
expect(modifyListLines([...listWithNoPrefixes], 1, '1. ')).toStrictEqual(listWithNumbers);
});
test('should remove "1. " at the beginning of each line of the input string', () => {
expect(modifyListLines([...listWithOnes], 2, '1. ')).toStrictEqual(listWithNoPrefixes);
});
test('should remove "n. " from each line that has it, and ignore' +
' lines which do not', () => {
expect(modifyListLines([...listWithSomeNumbers], 2, '2. ')).toStrictEqual(listWithNoPrefixes);
});
test('should add numbers to each line including empty one', () => {
expect(modifyListLines(noPrefixListWithEmptyLines, 1, '1. ')).toStrictEqual(numberedListWithEmptyLines);
});
});

View File

@@ -1,20 +1,27 @@
import markdownUtils from '@joplin/lib/markdownUtils';
import Setting from '@joplin/lib/models/Setting';
export function modifyListLines(lines: string[],num: number,listSymbol: string) {
export function modifyListLines(lines: string[], num: number, listSymbol: string) {
const isNotNumbered = num === 1;
for (let j = 0; j < lines.length; j++) {
const line = lines[j];
if (!line && j === lines.length - 1) continue;
// Only add the list token if it's not already there
// if it is, remove it
if (!line.startsWith(listSymbol)) {
if (num) {
if (num) {
const lineNum = markdownUtils.olLineNumber(line);
if (!lineNum && isNotNumbered) {
lines[j] = `${num.toString()}. ${line}`;
num++;
} else {
lines[j] = listSymbol + line;
const listToken = markdownUtils.extractListToken(line);
lines[j] = line.substr(listToken.length, line.length - listToken.length);
}
} else {
lines[j] = line.substr(listSymbol.length, line.length - listSymbol.length);
if (!line.startsWith(listSymbol)) {
lines[j] = listSymbol + line;
} else {
lines[j] = line.substr(listSymbol.length, line.length - listSymbol.length);
}
}
}
return lines;

View File

@@ -0,0 +1,7 @@
// Helper commands added to the the CodeMirror instance
export default function useJoplinCommands(CodeMirror: any) {
CodeMirror.defineExtension('commandExists', function(name: string) {
return !!CodeMirror.commands[name];
});
}

View File

@@ -9,6 +9,7 @@ const { urlDecode } = require('@joplin/lib/string-utils');
const urlUtils = require('@joplin/lib/urlUtils');
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import { reg } from '@joplin/lib/registry';
const uri2path = require('file-uri-to-path');
export default function useMessageHandler(scrollWhenReady: any, setScrollWhenReady: Function, editorRef: any, setLocalSearchResultCount: Function, dispatch: Function, formNote: FormNote) {
return useCallback(async (event: any) => {
@@ -51,8 +52,14 @@ export default function useMessageHandler(scrollWhenReady: any, setScrollWhenRea
} else if (urlUtils.urlProtocol(msg)) {
if (msg.indexOf('file://') === 0) {
// When using the file:// protocol, openPath doesn't work (does nothing) with URL-encoded paths
require('electron').shell.openPath(urlDecode(msg));
// When using the file:// protocol, openPath doesn't work (does
// nothing) with URL-encoded paths.
//
// shell.openPath seems to work with file:// urls on Windows,
// but doesn't on macOS, so we need to convert it to a path
// before passing it to openPath.
const decodedPath = uri2path(urlDecode(msg));
require('electron').shell.openPath(decodedPath);
} else {
require('electron').shell.openExternal(msg);
}

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

@@ -1,6 +1,5 @@
import { utils as pluginUtils, PluginStates } from '@joplin/lib/services/plugins/reducer';
import CommandService from '@joplin/lib/services/CommandService';
import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer';
import eventManager from '@joplin/lib/eventManager';
import InteropService from '@joplin/lib/services/interop/InteropService';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
@@ -134,7 +133,7 @@ export default class NoteListUtils {
})
);
if (Setting.value('sync.target') === SyncTargetJoplinServer.id()) {
if ([9, 10].includes(Setting.value('sync.target'))) {
menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('showShareNoteDialog', noteIds.slice())

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "2.0.4",
"version": "2.0.6",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "2.0.4",
"version": "2.0.6",
"description": "Joplin for Desktop",
"main": "main.js",
"private": true,

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

@@ -105,7 +105,7 @@ class SearchScreenComponent extends BaseScreenComponent {
if (query) {
if (this.props.settings['db.ftsEnabled']) {
notes = await SearchEngineUtils.notesForQuery(query);
notes = await SearchEngineUtils.notesForQuery(query, true);
} else {
const p = query.split(' ');
const temp = [];

View File

@@ -25,6 +25,7 @@ import { loadKeychainServiceAndSettings } from '@joplin/lib/services/SettingUtil
import KeychainServiceDriverMobile from '@joplin/lib/services/keychain/KeychainServiceDriver.mobile';
import { setLocale, closestSupportedLocale, defaultLocale } from '@joplin/lib/locale';
import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer';
import SyncTargetJoplinCloud from '@joplin/lib/SyncTargetJoplinCloud';
import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive';
const { AppState, Keyboard, NativeModules, BackHandler, Animated, View, StatusBar, Linking, Platform } = require('react-native');
@@ -90,6 +91,7 @@ SyncTargetRegistry.addClass(SyncTargetDropbox);
SyncTargetRegistry.addClass(SyncTargetFilesystem);
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
import FsDriverRN from './utils/fs-driver-rn';
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';

View File

@@ -143,6 +143,12 @@
}
}
},
"balanced-match": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.3.0.tgz",
"integrity": "sha1-qRzdHr7xqGZZ5w/03vAWJfwtZ1Y=",
"dev": true
},
"bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@@ -175,9 +181,11 @@
},
"brace-expansion": {
"version": "1.1.3",
"resolved": "",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.3.tgz",
"integrity": "sha1-Rr/1ARXUf8mriYVKu4fZgHihCZE=",
"dev": true,
"requires": {
"balanced-match": "^0.3.0",
"concat-map": "0.0.1"
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "generator-joplin",
"version": "1.8.1",
"version": "2.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,4 +1,4 @@
import Setting from './models/Setting';
import Setting, { Env } from './models/Setting';
import Logger, { TargetType, LoggerWrapper } from './Logger';
import shim from './shim';
import BaseService from './services/BaseService';
@@ -46,6 +46,7 @@ const { loadKeychainServiceAndSettings } = require('./services/SettingUtils');
import MigrationService from './services/MigrationService';
import ShareService from './services/share/ShareService';
import handleSyncStartupOperation from './services/synchronizer/utils/handleSyncStartupOperation';
import SyncTargetJoplinCloud from './SyncTargetJoplinCloud';
const { toSystemSlashes } = require('./path-utils');
const { setAutoFreeze } = require('immer');
@@ -312,7 +313,7 @@ export default class BaseApplication {
notes = await Tag.notes(parentId, options);
} else if (parentType === BaseModel.TYPE_SEARCH) {
const search = BaseModel.byId(state.searches, parentId);
notes = await SearchEngineUtils.notesForQuery(search.query_pattern);
notes = await SearchEngineUtils.notesForQuery(search.query_pattern, true);
const parsedQuery = await SearchEngine.instance().parseQuery(search.query_pattern);
highlightedWords = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
} else if (parentType === BaseModel.TYPE_SMART_FILTER) {
@@ -691,6 +692,7 @@ export default class BaseApplication {
SyncTargetRegistry.addClass(SyncTargetDropbox);
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
try {
await shim.fsDriver().remove(tempDir);
@@ -763,6 +765,13 @@ export default class BaseApplication {
setLocale(Setting.value('locale'));
}
if (Setting.value('env') === Env.Dev) {
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:
// https://discourse.joplinapp.org/t/1-1-4-keyboard-locks-up-while-typing/11231/11
// https://discourse.joplinapp.org/t/serious-lagging-when-there-are-tens-of-thousands-of-notes/11215/23

View File

@@ -10,6 +10,7 @@ const logger = Logger.create('JoplinServerApi');
interface Options {
baseUrl(): string;
userContentBaseUrl(): string;
username(): string;
password(): string;
env?: Env;
@@ -47,7 +48,7 @@ export default class JoplinServerApi {
this.options_ = options;
if (options.env === Env.Dev) {
this.debugRequests_ = true;
// this.debugRequests_ = true;
}
}
@@ -55,15 +56,24 @@ export default class JoplinServerApi {
return rtrimSlashes(this.options_.baseUrl());
}
public userContentBaseUrl() {
return this.options_.userContentBaseUrl() || this.baseUrl();
}
private async session() {
if (this.session_) return this.session_;
this.session_ = await this.exec('POST', 'api/sessions', null, {
email: this.options_.username(),
password: this.options_.password(),
});
try {
this.session_ = await this.exec('POST', 'api/sessions', null, {
email: this.options_.username(),
password: this.options_.password(),
});
return this.session_;
return this.session_;
} catch (error) {
logger.error('Could not acquire session:', error.details, '\n', error);
throw error;
}
}
private async sessionId() {
@@ -136,6 +146,8 @@ export default class JoplinServerApi {
url += stringify(query);
}
const startTime = Date.now();
try {
if (this.debugRequests_) {
logger.debug(this.requestToCurl_(url, fetchOptions));
@@ -160,16 +172,19 @@ export default class JoplinServerApi {
const responseText = await response.text();
if (this.debugRequests_) {
logger.debug('Response', 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
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;
@@ -195,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

@@ -0,0 +1,59 @@
import Setting from './models/Setting';
import Synchronizer from './Synchronizer';
import { _ } from './locale.js';
import BaseSyncTarget from './BaseSyncTarget';
import { FileApi } from './file-api';
import SyncTargetJoplinServer, { initFileApi } from './SyncTargetJoplinServer';
interface FileApiOptions {
path(): string;
userContentPath(): string;
username(): string;
password(): string;
}
export default class SyncTargetJoplinCloud extends BaseSyncTarget {
public static id() {
return 10;
}
public static supportsConfigCheck() {
return SyncTargetJoplinServer.supportsConfigCheck();
}
public static targetName() {
return 'joplinCloud';
}
public static label() {
return _('Joplin Cloud');
}
public async isAuthenticated() {
return true;
}
public async fileApi(): Promise<FileApi> {
return super.fileApi();
}
public static async checkConfig(options: FileApiOptions) {
return SyncTargetJoplinServer.checkConfig({
...options,
}, SyncTargetJoplinCloud.id());
}
protected async initFileApi() {
return initFileApi(SyncTargetJoplinCloud.id(), this.logger(), {
path: () => Setting.value('sync.10.path'),
userContentPath: () => Setting.value('sync.10.userContentPath'),
username: () => Setting.value('sync.10.username'),
password: () => Setting.value('sync.10.password'),
});
}
protected async initSynchronizer() {
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
}
}

View File

@@ -5,13 +5,38 @@ import { _ } from './locale.js';
import JoplinServerApi from './JoplinServerApi';
import BaseSyncTarget from './BaseSyncTarget';
import { FileApi } from './file-api';
import Logger from './Logger';
interface FileApiOptions {
path(): string;
userContentPath(): string;
username(): string;
password(): string;
}
export async function newFileApi(id: number, options: FileApiOptions) {
const apiOptions = {
baseUrl: () => options.path(),
userContentBaseUrl: () => options.userContentPath(),
username: () => options.username(),
password: () => options.password(),
env: Setting.value('env'),
};
const api = new JoplinServerApi(apiOptions);
const driver = new FileApiDriverJoplinServer(api);
const fileApi = new FileApi('', driver);
fileApi.setSyncTargetId(id);
await fileApi.initialize();
return fileApi;
}
export async function initFileApi(syncTargetId: number, logger: Logger, options: FileApiOptions) {
const fileApi = await newFileApi(syncTargetId, options);
fileApi.setLogger(logger);
return fileApi;
}
export default class SyncTargetJoplinServer extends BaseSyncTarget {
public static id() {
@@ -38,30 +63,16 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
return super.fileApi();
}
private static async newFileApi_(options: FileApiOptions) {
const apiOptions = {
baseUrl: () => options.path(),
username: () => options.username(),
password: () => options.password(),
env: Setting.value('env'),
};
const api = new JoplinServerApi(apiOptions);
const driver = new FileApiDriverJoplinServer(api);
const fileApi = new FileApi('', driver);
fileApi.setSyncTargetId(this.id());
await fileApi.initialize();
return 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 SyncTargetJoplinServer.newFileApi_(options);
const fileApi = await newFileApi(syncTargetId, options);
fileApi.requestRepeatCount_ = 0;
await fileApi.put('testing.txt', 'testing');
@@ -78,15 +89,12 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
}
protected async initFileApi() {
const fileApi = await SyncTargetJoplinServer.newFileApi_({
return initFileApi(SyncTargetJoplinServer.id(), this.logger(), {
path: () => Setting.value('sync.9.path'),
userContentPath: () => Setting.value('sync.9.userContentPath'),
username: () => Setting.value('sync.9.username'),
password: () => Setting.value('sync.9.password'),
});
fileApi.setLogger(this.logger());
return fileApi;
}
protected async initSynchronizer() {

View File

@@ -24,7 +24,7 @@ class SyncTargetRegistry {
if (!this.reg_.hasOwnProperty(n)) continue;
if (this.reg_[n].name === name) return this.reg_[n].id;
}
throw new Error(`Name not found: ${name}`);
throw new Error(`Name not found: ${name}. Was the sync target registered?`);
}
static idToMetadata(id) {

View File

@@ -926,6 +926,7 @@ export default class Synchronizer {
this.logger().error(error);
} else {
this.logger().error(error);
if (error.details) this.logger().error('Details:', error.details);
// Don't save to the report errors that are due to things like temporary network errors or timeout.
if (!shim.fetchRequestCanBeRetried(error)) {

View File

@@ -80,7 +80,19 @@ export default class FileApiDriverJoplinServer {
const response = await this.api().exec('GET', `${this.apiFilePath_(path)}/delta`, query);
const stats = response.items
.filter((item: any) => {
return item.item_name.indexOf('locks/') !== 0 && item.item_name.indexOf('temp/') !== 0;
// We don't need to know about lock changes, since this
// is handled by the LockHandler.
if (item.item_name.indexOf('locks/') === 0) return false;
// We don't need to sync what's in the temp folder
if (item.item_name.indexOf('temp/') === 0) return false;
// Although we sync the content of .resource, whether we
// fetch or upload data to it is driven by the
// associated resource item (.md) file. So at this point
// we don't want to automatically fetch from it.
if (item.item_name.indexOf('.resource/') === 0) return false;
return true;
})
.map((item: any) => {
return this.metadataToStat_(item, item.item_name, item.type === 3, '');
@@ -171,6 +183,12 @@ export default class FileApiDriverJoplinServer {
}
public async clearRoot(path: string) {
await this.delete(path);
const response = await this.list(path);
for (const item of response.items) {
await this.delete(item.path);
}
if (response.has_more) throw new Error('has_more support not implemented');
}
}

View File

@@ -41,45 +41,45 @@ locales['uk_UA'] = require('./uk_UA.json');
locales['vi'] = require('./vi.json');
locales['zh_CN'] = require('./zh_CN.json');
locales['zh_TW'] = require('./zh_TW.json');
stats['ar'] = {"percentDone":96};
stats['ar'] = {"percentDone":95};
stats['eu'] = {"percentDone":30};
stats['bs_BA'] = {"percentDone":75};
stats['bg_BG'] = {"percentDone":58};
stats['ca'] = {"percentDone":83};
stats['bs_BA'] = {"percentDone":74};
stats['bg_BG'] = {"percentDone":57};
stats['ca'] = {"percentDone":82};
stats['hr_HR'] = {"percentDone":96};
stats['cs_CZ'] = {"percentDone":86};
stats['da_DK'] = {"percentDone":96};
stats['de_DE'] = {"percentDone":95};
stats['et_EE'] = {"percentDone":57};
stats['cs_CZ'] = {"percentDone":85};
stats['da_DK'] = {"percentDone":95};
stats['de_DE'] = {"percentDone":94};
stats['et_EE'] = {"percentDone":56};
stats['en_GB'] = {"percentDone":100};
stats['en_US'] = {"percentDone":100};
stats['es_ES'] = {"percentDone":94};
stats['eo'] = {"percentDone":33};
stats['eo'] = {"percentDone":32};
stats['fi_FI'] = {"percentDone":94};
stats['fr_FR'] = {"percentDone":99};
stats['fr_FR'] = {"percentDone":98};
stats['gl_ES'] = {"percentDone":38};
stats['id_ID'] = {"percentDone":93};
stats['it_IT'] = {"percentDone":94};
stats['id_ID'] = {"percentDone":92};
stats['it_IT'] = {"percentDone":99};
stats['hu_HU'] = {"percentDone":88};
stats['nl_BE'] = {"percentDone":92};
stats['nl_NL'] = {"percentDone":95};
stats['nb_NO'] = {"percentDone":76};
stats['nl_BE'] = {"percentDone":91};
stats['nl_NL'] = {"percentDone":94};
stats['nb_NO'] = {"percentDone":75};
stats['fa'] = {"percentDone":71};
stats['pl_PL'] = {"percentDone":94};
stats['pt_BR'] = {"percentDone":94};
stats['pt_PT'] = {"percentDone":94};
stats['ro'] = {"percentDone":66};
stats['sl_SI'] = {"percentDone":96};
stats['sl_SI'] = {"percentDone":95};
stats['sv'] = {"percentDone":61};
stats['th_TH'] = {"percentDone":45};
stats['vi'] = {"percentDone":73};
stats['tr_TR'] = {"percentDone":94};
stats['uk_UA'] = {"percentDone":94};
stats['el_GR'] = {"percentDone":97};
stats['el_GR'] = {"percentDone":96};
stats['ru_RU'] = {"percentDone":94};
stats['sr_RS'] = {"percentDone":71};
stats['zh_CN'] = {"percentDone":94};
stats['zh_TW'] = {"percentDone":92};
stats['ja_JP'] = {"percentDone":97};
stats['ko'] = {"percentDone":96};
stats['zh_CN'] = {"percentDone":99};
stats['zh_TW'] = {"percentDone":99};
stats['ja_JP'] = {"percentDone":96};
stats['ko'] = {"percentDone":99};
module.exports = { locales: locales, stats: stats };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

@@ -476,6 +476,12 @@ class Setting extends BaseModel {
description: () => emptyDirWarning,
storage: SettingStorage.File,
},
'sync.9.userContentPath': {
value: '',
type: SettingItemType.String,
public: false,
storage: SettingStorage.Database,
},
'sync.9.username': {
value: '',
type: SettingItemType.String,
@@ -499,6 +505,45 @@ class Setting extends BaseModel {
secure: true,
},
// Although sync.10.path is essentially a constant, we still define
// it here so that both Joplin Server and Joplin Cloud can be
// handled in the same consistent way. Also having it a setting
// means it can be set to something else for development.
'sync.10.path': {
value: 'https://api.joplincloud.com',
type: SettingItemType.String,
public: false,
storage: SettingStorage.Database,
},
'sync.10.userContentPath': {
value: 'https://joplinusercontent.com',
type: SettingItemType.String,
public: false,
storage: SettingStorage.Database,
},
'sync.10.username': {
value: '',
type: SettingItemType.String,
section: 'sync',
show: (settings: any) => {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinCloud');
},
public: true,
label: () => _('Joplin Cloud email'),
storage: SettingStorage.File,
},
'sync.10.password': {
value: '',
type: SettingItemType.String,
section: 'sync',
show: (settings: any) => {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinCloud');
},
public: true,
label: () => _('Joplin Cloud password'),
secure: true,
},
'sync.5.syncTargets': { value: {}, type: SettingItemType.Object, public: false },
'sync.resourceDownloadMode': {
@@ -525,6 +570,7 @@ class Setting extends BaseModel {
'sync.4.auth': { value: '', type: SettingItemType.String, public: false },
'sync.7.auth': { value: '', type: SettingItemType.String, public: false },
'sync.9.auth': { value: '', type: SettingItemType.String, public: false },
'sync.10.auth': { value: '', type: SettingItemType.String, public: false },
'sync.1.context': { value: '', type: SettingItemType.String, public: false },
'sync.2.context': { value: '', type: SettingItemType.String, public: false },
'sync.3.context': { value: '', type: SettingItemType.String, public: false },
@@ -534,6 +580,7 @@ class Setting extends BaseModel {
'sync.7.context': { value: '', type: SettingItemType.String, public: false },
'sync.8.context': { value: '', type: SettingItemType.String, public: false },
'sync.9.context': { value: '', type: SettingItemType.String, public: false },
'sync.10.context': { value: '', type: SettingItemType.String, public: false },
'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, storage: SettingStorage.File, public: true, advanced: true, section: 'sync', label: () => _('Max concurrent connections'), minimum: 1, maximum: 20, step: 1 },

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

@@ -77,6 +77,6 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false,
folderIsShared: commandFolder ? !!commandFolder.share_id : false,
joplinServerConnected: state.settings['sync.target'] === 9,
joplinServerConnected: [9, 10].includes(state.settings['sync.target']),
};
}

View File

@@ -21,6 +21,34 @@ import { Command } from './types';
*
* To view what arguments are supported, you can open any of these files
* and look at the `execute()` command.
*
* ## Executing editor commands
*
* There might be a situation where you want to invoke editor commands
* without using a {@link JoplinContentScripts | contentScript}. For this
* reason Joplin provides the built in `editor.execCommand` command.
*
* `editor.execCommand` should work with any core command in both the
* [CodeMirror](https://codemirror.net/doc/manual.html#execCommand) and
* [TinyMCE](https://www.tiny.cloud/docs/api/tinymce/tinymce.editorcommands/#execcommand) editors,
* as well as most functions calls directly on a CodeMirror editor object (extensions).
*
* * [CodeMirror commands](https://codemirror.net/doc/manual.html#commands)
* * [TinyMCE core editor commands](https://www.tiny.cloud/docs/advanced/editor-command-identifiers/#coreeditorcommands)
*
* `editor.execCommand` supports adding arguments for the commands.
*
* ```typescript
* await joplin.commands.execute('editor.execCommand', {
* name: 'madeUpCommand', // CodeMirror and TinyMCE
* args: [], // CodeMirror and TinyMCE
* ui: false, // TinyMCE only
* value: '', // TinyMCE only
* });
* ```
*
* [View the example using the CodeMirror editor](https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/support/plugins/codemirror_content_script/src/index.ts)
*
*/
export default class JoplinCommands {

View File

@@ -28,7 +28,7 @@ export default async function(request: Request) {
options.caseInsensitive = true;
results = await ModelClass.all(options);
} else {
results = await SearchEngineUtils.notesForQuery(query, defaultLoadOptions(request, ModelType.Note));
results = await SearchEngineUtils.notesForQuery(query, false, defaultLoadOptions(request, ModelType.Note));
}
return collectionToPaginatedResults(modelType, results, request);

View File

@@ -386,6 +386,7 @@ describe('services_SearchEngine', function() {
expect((await engine.search('测试')).length).toBe(1);
expect((await engine.search('测试'))[0].fields).toEqual(['body']);
expect((await engine.search('测试*'))[0].fields).toEqual(['body']);
expect((await engine.search('any:1 type:todo 测试')).length).toBe(1);
}));
it('should support queries with Japanese characters', (async () => {
@@ -398,7 +399,7 @@ describe('services_SearchEngine', function() {
expect((await engine.search('できません')).length).toBe(1);
expect((await engine.search('できません*'))[0].fields.sort()).toEqual(['body', 'title']); // usually assume that keyword was matched in body
expect((await engine.search('テスト'))[0].fields.sort()).toEqual(['body']);
expect((await engine.search('any:1 type:todo テスト')).length).toBe(1);
}));
it('should support queries with Korean characters', (async () => {
@@ -409,6 +410,7 @@ describe('services_SearchEngine', function() {
expect((await engine.search('이것은')).length).toBe(1);
expect((await engine.search('말')).length).toBe(1);
expect((await engine.search('any:1 type:todo 말')).length).toBe(1);
}));
it('should support queries with Thai characters', (async () => {
@@ -419,28 +421,7 @@ describe('services_SearchEngine', function() {
expect((await engine.search('นี่คือค')).length).toBe(1);
expect((await engine.search('ไทย')).length).toBe(1);
}));
it('should support field restricted queries with Chinese characters', (async () => {
let rows;
const n1 = await Note.save({ title: '你好', body: '我是法国人' });
await engine.syncTables();
expect((await engine.search('title:你好*')).length).toBe(1);
expect((await engine.search('title:你好*'))[0].fields).toEqual(['title']);
expect((await engine.search('body:法国人')).length).toBe(1);
expect((await engine.search('body:法国人'))[0].fields).toEqual(['body']);
expect((await engine.search('body:你好')).length).toBe(0);
expect((await engine.search('title:你好 body:法国人')).length).toBe(1);
expect((await engine.search('title:你好 body:法国人'))[0].fields.sort()).toEqual(['body', 'title']);
expect((await engine.search('title:你好 body:bla')).length).toBe(0);
expect((await engine.search('title:你好 我是')).length).toBe(1);
expect((await engine.search('title:你好 我是'))[0].fields.sort()).toEqual(['body', 'title']);
expect((await engine.search('title:bla 我是')).length).toBe(0);
// For non-alpha char, only the first field is looked at, the following ones are ignored
// expect((await engine.search('title:你好 title:hello')).length).toBe(1);
expect((await engine.search('any:1 type:todo ไทย')).length).toBe(1);
}));
it('should parse normal query strings', (async () => {

View File

@@ -17,6 +17,7 @@ export default class SearchEngine {
public static relevantFields = 'id, title, body, user_created_time, user_updated_time, is_todo, todo_completed, todo_due, parent_id, latitude, longitude, altitude, source_url';
public static SEARCH_TYPE_AUTO = 'auto';
public static SEARCH_TYPE_BASIC = 'basic';
public static SEARCH_TYPE_NONLATIN_SCRIPT = 'nonlatin';
public static SEARCH_TYPE_FTS = 'fts';
public dispatch: Function = (_o: any) => {};
@@ -533,6 +534,7 @@ export default class SearchEngine {
determineSearchType_(query: string, preferredSearchType: any) {
if (preferredSearchType === SearchEngine.SEARCH_TYPE_BASIC) return SearchEngine.SEARCH_TYPE_BASIC;
if (preferredSearchType === SearchEngine.SEARCH_TYPE_NONLATIN_SCRIPT) return SearchEngine.SEARCH_TYPE_NONLATIN_SCRIPT;
// If preferredSearchType is "fts" we auto-detect anyway
// because it's not always supported.
@@ -547,10 +549,15 @@ export default class SearchEngine {
const textQuery = allTerms.filter(x => x.name === 'text' || x.name == 'title' || x.name == 'body').map(x => x.value).join(' ');
const st = scriptType(textQuery);
if (!Setting.value('db.ftsEnabled') || ['ja', 'zh', 'ko', 'th'].indexOf(st) >= 0) {
if (!Setting.value('db.ftsEnabled')) {
return SearchEngine.SEARCH_TYPE_BASIC;
}
// Non-alphabetical languages aren't support by SQLite FTS (except with extensions which are not available in all platforms)
if (['ja', 'zh', 'ko', 'th'].indexOf(st) >= 0) {
return SearchEngine.SEARCH_TYPE_NONLATIN_SCRIPT;
}
return SearchEngine.SEARCH_TYPE_FTS;
}
@@ -565,7 +572,6 @@ export default class SearchEngine {
const parsedQuery = await this.parseQuery(searchString);
if (searchType === SearchEngine.SEARCH_TYPE_BASIC) {
// Non-alphabetical languages aren't support by SQLite FTS (except with extensions which are not available in all platforms)
searchString = this.normalizeText_(searchString);
const rows = await this.basicSearch(searchString);
@@ -579,10 +585,11 @@ export default class SearchEngine {
// when searching.
// https://github.com/laurent22/joplin/issues/1075#issuecomment-459258856
const useFts = searchType === SearchEngine.SEARCH_TYPE_FTS;
try {
const { query, params } = queryBuilder(parsedQuery.allTerms);
const { query, params } = queryBuilder(parsedQuery.allTerms, useFts);
const rows = await this.db().selectAll(query, params);
this.processResults_(rows, parsedQuery);
this.processResults_(rows, parsedQuery, !useFts);
return rows;
} catch (error) {
this.logger().warn(`Cannot execute MATCH query: ${searchString}: ${error.message}`);

View File

@@ -26,12 +26,21 @@ describe('services_SearchEngineUtils', function() {
Setting.setValue('showCompletedTodos', true);
const rows = await SearchEngineUtils.notesForQuery('abcd', null, searchEngine);
const rows = await SearchEngineUtils.notesForQuery('abcd', true, null, searchEngine);
expect(rows.length).toBe(3);
expect(rows.map(r=>r.id)).toContain(note1.id);
expect(rows.map(r=>r.id)).toContain(todo1.id);
expect(rows.map(r=>r.id)).toContain(todo2.id);
const options: any = {};
options.fields = ['id', 'title'];
const rows2 = await SearchEngineUtils.notesForQuery('abcd', true, options, searchEngine);
expect(rows2.length).toBe(3);
expect(rows2.map(r=>r.id)).toContain(note1.id);
expect(rows2.map(r=>r.id)).toContain(todo1.id);
expect(rows2.map(r=>r.id)).toContain(todo2.id);
}));
it('hide completed', (async () => {
@@ -43,11 +52,35 @@ describe('services_SearchEngineUtils', function() {
Setting.setValue('showCompletedTodos', false);
const rows = await SearchEngineUtils.notesForQuery('abcd', null, searchEngine);
const rows = await SearchEngineUtils.notesForQuery('abcd', true, null, searchEngine);
expect(rows.length).toBe(2);
expect(rows.map(r=>r.id)).toContain(note1.id);
expect(rows.map(r=>r.id)).toContain(todo1.id);
const options: any = {};
options.fields = ['id', 'title'];
const rows2 = await SearchEngineUtils.notesForQuery('abcd', true, options, searchEngine);
expect(rows2.length).toBe(2);
expect(rows2.map(r=>r.id)).toContain(note1.id);
expect(rows2.map(r=>r.id)).toContain(todo1.id);
}));
it('show completed (!applyUserSettings)', (async () => {
const note1 = await Note.save({ title: 'abcd', body: 'body 1' });
const todo1 = await Note.save({ title: 'abcd', body: 'todo 1', is_todo: 1 });
await Note.save({ title: 'qwer', body: 'body 2' });
const todo2 = await Note.save({ title: 'abcd', body: 'todo 2', is_todo: 1, todo_completed: 1590085027710 });
await searchEngine.syncTables();
Setting.setValue('showCompletedTodos', false);
const rows = await SearchEngineUtils.notesForQuery('abcd', false, null, searchEngine);
expect(rows.length).toBe(3);
expect(rows.map(r=>r.id)).toContain(note1.id);
expect(rows.map(r=>r.id)).toContain(todo1.id);
expect(rows.map(r=>r.id)).toContain(todo2.id);
}));
});
});

View File

@@ -3,7 +3,7 @@ import Note from '../../models/Note';
import Setting from '../../models/Setting';
export default class SearchEngineUtils {
static async notesForQuery(query: string, options: any = null, searchEngine: SearchEngine = null) {
static async notesForQuery(query: string, applyUserSettings: boolean, options: any = null, searchEngine: SearchEngine = null) {
if (!options) options = {};
if (!searchEngine) {
@@ -30,6 +30,20 @@ export default class SearchEngineUtils {
idWasAutoAdded = true;
}
// Add fields is_todo and todo_completed for showCompletedTodos filtering.
// Also remember that the field was auto-added so that it can be removed afterwards.
let isTodoAutoAdded = false;
if (fields.indexOf('is_todo') < 0) {
fields.push('is_todo');
isTodoAutoAdded = true;
}
let isTodoCompletedAutoAdded = false;
if (fields.indexOf('todo_completed') < 0) {
fields.push('todo_completed');
isTodoCompletedAutoAdded = true;
}
const previewOptions = Object.assign({}, {
order: [],
fields: fields,
@@ -38,20 +52,22 @@ export default class SearchEngineUtils {
const notes = await Note.previews(null, previewOptions);
// Filter completed todos
let filteredNotes = [...notes];
if (applyUserSettings && !Setting.value('showCompletedTodos')) {
filteredNotes = notes.filter(note => note.is_todo === 0 || (note.is_todo === 1 && note.todo_completed === 0));
}
// By default, the notes will be returned in reverse order
// or maybe random order so sort them here in the correct order
// (search engine returns the results in order of relevance).
const sortedNotes = [];
for (let i = 0; i < notes.length; i++) {
const idx = noteIds.indexOf(notes[i].id);
sortedNotes[idx] = notes[i];
for (let i = 0; i < filteredNotes.length; i++) {
const idx = noteIds.indexOf(filteredNotes[i].id);
sortedNotes[idx] = filteredNotes[i];
if (idWasAutoAdded) delete sortedNotes[idx].id;
}
// Filter completed todos
let filteredNotes = [...sortedNotes];
if (!Setting.value('showCompletedTodos')) {
filteredNotes = sortedNotes.filter(note => note.is_todo === 0 || (note.is_todo === 1 && note.todo_completed === 0));
if (isTodoCompletedAutoAdded) delete sortedNotes[idx].is_todo;
if (isTodoAutoAdded) delete sortedNotes[idx].todo_completed;
}
// Note that when the search engine index is somehow corrupted, it might contain
@@ -60,9 +76,9 @@ export default class SearchEngineUtils {
// issue: https://discourse.joplinapp.org/t/how-to-recover-corrupted-database/9367
if (noteIds.length !== notes.length) {
// remove null objects
return filteredNotes.filter(n => n);
return sortedNotes.filter(n => n);
} else {
return filteredNotes;
return sortedNotes;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ enum Requirement {
INCLUSION = 'INCLUSION',
}
const _notebookFilter = (notebooks: string[], requirement: Requirement, conditions: string[], params: string[], withs: string[]) => {
const _notebookFilter = (notebooks: string[], requirement: Requirement, conditions: string[], params: string[], withs: string[], useFts: boolean) => {
if (notebooks.length === 0) return;
const likes = [];
@@ -50,12 +50,13 @@ const _notebookFilter = (notebooks: string[], requirement: Requirement, conditio
ON folders.parent_id=${viewName}.id
)`;
const tableName = useFts ? 'notes_normalized' : 'notes';
const where = `
AND ROWID ${requirement === Requirement.EXCLUSION ? 'NOT' : ''} IN (
SELECT notes_normalized.ROWID
SELECT ${tableName}.ROWID
FROM ${viewName}
JOIN notes_normalized
ON ${viewName}.id=notes_normalized.parent_id
JOIN ${tableName}
ON ${viewName}.id=${tableName}.parent_id
)`;
@@ -65,12 +66,12 @@ const _notebookFilter = (notebooks: string[], requirement: Requirement, conditio
};
const notebookFilter = (terms: Term[], conditions: string[], params: string[], withs: string[]) => {
const notebookFilter = (terms: Term[], conditions: string[], params: string[], withs: string[], useFts: boolean) => {
const notebooksToInclude = terms.filter(x => x.name === 'notebook' && !x.negated).map(x => x.value);
_notebookFilter(notebooksToInclude, Requirement.INCLUSION, conditions, params, withs);
_notebookFilter(notebooksToInclude, Requirement.INCLUSION, conditions, params, withs, useFts);
const notebooksToExclude = terms.filter(x => x.name === 'notebook' && x.negated).map(x => x.value);
_notebookFilter(notebooksToExclude, Requirement.EXCLUSION, conditions, params, withs);
_notebookFilter(notebooksToExclude, Requirement.EXCLUSION, conditions, params, withs, useFts);
};
@@ -87,7 +88,8 @@ const filterByTableName = (
noteIDs: string,
requirement: Requirement,
withs: string[],
tableName: string
tableName: string,
useFts: boolean
) => {
const operator: Operation = getOperator(requirement, relation);
@@ -144,13 +146,14 @@ const filterByTableName = (
}
// Get the ROWIDs that satisfy the condition so we can filter the result
const targetTableName = useFts ? 'notes_normalized' : 'notes';
const whereCondition = `
${relation} ROWID ${(relation === 'AND' && requirement === 'EXCLUSION') ? 'NOT' : ''}
IN (
SELECT notes_normalized.ROWID
SELECT ${targetTableName}.ROWID
FROM notes_with_${requirement}_${tableName}
JOIN notes_normalized
ON notes_with_${requirement}_${tableName}.id=notes_normalized.id
JOIN ${targetTableName}
ON notes_with_${requirement}_${tableName}.id=${targetTableName}.id
)`;
withs.push(withCondition);
@@ -159,7 +162,7 @@ const filterByTableName = (
};
const resourceFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, withs: string[]) => {
const resourceFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, withs: string[], useFts: boolean) => {
const tableName = 'resources';
const resourceIDs = `
@@ -177,15 +180,15 @@ const resourceFilter = (terms: Term[], conditions: string[], params: string[], r
const excludedResources = terms.filter(x => x.name === 'resource' && x.negated);
if (requiredResources.length > 0) {
filterByTableName(requiredResources, conditions, params, relation, noteIDsWithResource, Requirement.INCLUSION, withs, tableName);
filterByTableName(requiredResources, conditions, params, relation, noteIDsWithResource, Requirement.INCLUSION, withs, tableName, useFts);
}
if (excludedResources.length > 0) {
filterByTableName(excludedResources, conditions, params, relation, noteIDsWithResource, Requirement.EXCLUSION, withs, tableName);
filterByTableName(excludedResources, conditions, params, relation, noteIDsWithResource, Requirement.EXCLUSION, withs, tableName, useFts);
}
};
const tagFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, withs: string[]) => {
const tagFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, withs: string[], useFts: boolean) => {
const tableName = 'tags';
const tagIDs = `
@@ -203,30 +206,32 @@ const tagFilter = (terms: Term[], conditions: string[], params: string[], relati
const excludedTags = terms.filter(x => x.name === 'tag' && x.negated);
if (requiredTags.length > 0) {
filterByTableName(requiredTags, conditions, params, relation, noteIDsWithTag, Requirement.INCLUSION, withs, tableName);
filterByTableName(requiredTags, conditions, params, relation, noteIDsWithTag, Requirement.INCLUSION, withs, tableName, useFts);
}
if (excludedTags.length > 0) {
filterByTableName(excludedTags, conditions, params, relation, noteIDsWithTag, Requirement.EXCLUSION, withs, tableName);
filterByTableName(excludedTags, conditions, params, relation, noteIDsWithTag, Requirement.EXCLUSION, withs, tableName, useFts);
}
};
const genericFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, fieldName: string) => {
const genericFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, fieldName: string, useFts: boolean) => {
if (fieldName === 'iscompleted' || fieldName === 'type') {
// Faster query when values can only take two distinct values
biConditionalFilter(terms, conditions, relation, fieldName);
biConditionalFilter(terms, conditions, relation, fieldName, useFts);
return;
}
const tableName = useFts ? 'notes_normalized' : 'notes';
const getCondition = (term: Term) => {
if (fieldName === 'sourceurl') {
return `notes_normalized.source_url ${term.negated ? 'NOT' : ''} LIKE ?`;
return `${tableName}.source_url ${term.negated ? 'NOT' : ''} LIKE ?`;
} else if (fieldName === 'date' && term.name === 'due') {
return `todo_due ${term.negated ? '<' : '>='} ?`;
} else if (fieldName === 'id') {
return `id ${term.negated ? 'NOT' : ''} LIKE ?`;
} else {
return `notes_normalized.${fieldName === 'date' ? `user_${term.name}_time` : `${term.name}`} ${term.negated ? '<' : '>='} ?`;
return `${tableName}.${fieldName === 'date' ? `user_${term.name}_time` : `${term.name}`} ${term.negated ? '<' : '>='} ?`;
}
};
@@ -234,16 +239,16 @@ const genericFilter = (terms: Term[], conditions: string[], params: string[], re
conditions.push(`
${relation} ( ${term.name === 'due' ? 'is_todo IS 1 AND ' : ''} ROWID IN (
SELECT ROWID
FROM notes_normalized
FROM ${tableName}
WHERE ${getCondition(term)}
))`);
params.push(term.value);
});
};
const biConditionalFilter = (terms: Term[], conditions: string[], relation: Relation, filterName: string) => {
const biConditionalFilter = (terms: Term[], conditions: string[], relation: Relation, filterName: string, useFts: boolean) => {
const getCondition = (filterName: string , value: string, relation: Relation) => {
const tableName = (relation === 'AND') ? 'notes_fts' : 'notes_normalized';
const tableName = useFts ? (relation === 'AND' ? 'notes_fts' : 'notes_normalized') : 'notes';
if (filterName === 'type') {
return `${tableName}.is_todo IS ${value === 'todo' ? 1 : 0}`;
} else if (filterName === 'iscompleted') {
@@ -262,39 +267,44 @@ const biConditionalFilter = (terms: Term[], conditions: string[], relation: Rela
AND ${getCondition(filterName, value, relation)}`);
}
if (relation === 'OR') {
conditions.push(`
OR ROWID IN (
SELECT ROWID
FROM notes_normalized
WHERE ${getCondition(filterName, value, relation)}
)`);
if (useFts) {
conditions.push(`
OR ROWID IN (
SELECT ROWID
FROM notes_normalized
WHERE ${getCondition(filterName, value, relation)}
)`);
} else {
conditions.push(`
OR ${getCondition(filterName, value, relation)}`);
}
}
});
};
const noteIdFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation) => {
const noteIdFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, useFts: boolean) => {
const noteIdTerms = terms.filter(x => x.name === 'id');
genericFilter(noteIdTerms, conditions, params, relation, 'id');
genericFilter(noteIdTerms, conditions, params, relation, 'id', useFts);
};
const typeFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation) => {
const typeFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, useFts: boolean) => {
const typeTerms = terms.filter(x => x.name === 'type');
genericFilter(typeTerms, conditions, params, relation, 'type');
genericFilter(typeTerms, conditions, params, relation, 'type', useFts);
};
const completedFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation) => {
const completedFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, useFts: boolean) => {
const completedTerms = terms.filter(x => x.name === 'iscompleted');
genericFilter(completedTerms, conditions, params, relation, 'iscompleted');
genericFilter(completedTerms, conditions, params, relation, 'iscompleted', useFts);
};
const locationFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation) => {
const locationFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation, useFts: boolean) => {
const locationTerms = terms.filter(x => x.name === 'latitude' || x.name === 'longitude' || x.name === 'altitude');
genericFilter(locationTerms, conditons, params, relation, 'location');
genericFilter(locationTerms, conditons, params, relation, 'location', useFts);
};
const dateFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation) => {
const dateFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation, useFts: boolean) => {
const getUnixMs = (date: string): string => {
const yyyymmdd = /^[0-9]{8}$/;
const yyyymm = /^[0-9]{6}$/;
@@ -321,44 +331,61 @@ const dateFilter = (terms: Term[], conditons: string[], params: string[], relati
const dateTerms = terms.filter(x => x.name === 'created' || x.name === 'updated' || x.name === 'due');
const unixDateTerms = dateTerms.map(term => { return { ...term, value: getUnixMs(term.value) }; });
genericFilter(unixDateTerms, conditons, params, relation, 'date');
genericFilter(unixDateTerms, conditons, params, relation, 'date', useFts);
};
const sourceUrlFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation) => {
const sourceUrlFilter = (terms: Term[], conditons: string[], params: string[], relation: Relation, useFts: boolean) => {
const urlTerms = terms.filter(x => x.name === 'sourceurl');
genericFilter(urlTerms, conditons, params, relation, 'sourceurl');
genericFilter(urlTerms, conditons, params, relation, 'sourceurl', useFts);
};
const trimQuotes = (str: string) => str.startsWith('"') && str.endsWith('"') ? str.substr(1, str.length - 2) : str;
const textFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation, useFts: boolean) => {
const createLikeMatch = (term: Term, negate: boolean) => {
const query = `${relation} ${negate ? 'NOT' : ''} (
${(term.name === 'text' || term.name === 'body') ? 'notes.body LIKE ? ' : ''}
${term.name === 'text' ? 'OR' : ''}
${(term.name === 'text' || term.name === 'title') ? 'notes.title LIKE ? ' : ''})`;
conditions.push(query);
const param = `%${trimQuotes(term.value).replace(/\*/, '%')}%`;
params.push(param);
if (term.name === 'text') params.push(param);
};
const textFilter = (terms: Term[], conditions: string[], params: string[], relation: Relation) => {
const addExcludeTextConditions = (excludedTerms: Term[], conditions: string[], params: string[], relation: Relation) => {
const type = excludedTerms[0].name === 'text' ? '' : `.${excludedTerms[0].name}`;
if (relation === 'AND') {
conditions.push(`
AND ROWID NOT IN (
SELECT ROWID
FROM notes_fts
WHERE notes_fts${type} MATCH ?
)`);
params.push(excludedTerms.map(x => x.value).join(' OR '));
}
if (relation === 'OR') {
excludedTerms.forEach(term => {
if (useFts) {
const type = excludedTerms[0].name === 'text' ? '' : `.${excludedTerms[0].name}`;
if (relation === 'AND') {
conditions.push(`
OR ROWID IN (
SELECT *
FROM (
SELECT ROWID
FROM notes_fts
EXCEPT
SELECT ROWID
FROM notes_fts
WHERE notes_fts${type} MATCH ?
)
AND ROWID NOT IN (
SELECT ROWID
FROM notes_fts
WHERE notes_fts${type} MATCH ?
)`);
params.push(term.value);
params.push(excludedTerms.map(x => x.value).join(' OR '));
}
if (relation === 'OR') {
excludedTerms.forEach(term => {
conditions.push(`
OR ROWID IN (
SELECT *
FROM (
SELECT ROWID
FROM notes_fts
EXCEPT
SELECT ROWID
FROM notes_fts
WHERE notes_fts${type} MATCH ?
)
)`);
params.push(term.value);
});
}
} else {
excludedTerms.forEach(term => {
createLikeMatch(term, true);
});
}
};
@@ -367,13 +394,19 @@ const textFilter = (terms: Term[], conditions: string[], params: string[], relat
const includedTerms = allTerms.filter(x => !x.negated);
if (includedTerms.length > 0) {
conditions.push(`${relation} notes_fts MATCH ?`);
const termsToMatch = includedTerms.map(term => {
if (term.name === 'text') return term.value;
else return `${term.name}:${term.value}`;
});
const matchQuery = (relation === 'OR') ? termsToMatch.join(' OR ') : termsToMatch.join(' ');
params.push(matchQuery);
if (useFts) {
conditions.push(`${relation} notes_fts MATCH ?`);
const termsToMatch = includedTerms.map(term => {
if (term.name === 'text') return term.value;
else return `${term.name}:${term.value}`;
});
const matchQuery = (relation === 'OR') ? termsToMatch.join(' OR ') : termsToMatch.join(' ');
params.push(matchQuery);
} else {
includedTerms.forEach(term => {
createLikeMatch(term, false);
});
}
}
const excludedTextTerms = allTerms.filter(x => x.name === 'text' && x.negated);
@@ -404,47 +437,48 @@ const getConnective = (terms: Term[], relation: Relation): string => {
return (!notebookTerm && (relation === 'OR')) ? 'ROWID=-1' : '1'; // ROWID=-1 acts as 0 (something always false)
};
export default function queryBuilder(terms: Term[]) {
export default function queryBuilder(terms: Term[], useFts: boolean) {
const queryParts: string[] = [];
const params: string[] = [];
const withs: string[] = [];
const relation: Relation = getDefaultRelation(terms);
const tableName = useFts ? 'notes_fts' : 'notes';
queryParts.push(`
SELECT
notes_fts.id,
notes_fts.title,
offsets(notes_fts) AS offsets,
matchinfo(notes_fts, 'pcnalx') AS matchinfo,
notes_fts.user_created_time,
notes_fts.user_updated_time,
notes_fts.is_todo,
notes_fts.todo_completed,
notes_fts.parent_id
FROM notes_fts
${tableName}.id,
${tableName}.title,
${useFts ? 'offsets(notes_fts) AS offsets, matchinfo(notes_fts, \'pcnalx\') AS matchinfo,' : ''}
${tableName}.user_created_time,
${tableName}.user_updated_time,
${tableName}.is_todo,
${tableName}.todo_completed,
${tableName}.parent_id
FROM ${tableName}
WHERE ${getConnective(terms, relation)}`);
noteIdFilter(terms, queryParts, params, relation);
noteIdFilter(terms, queryParts, params, relation, useFts);
notebookFilter(terms, queryParts, params, withs);
notebookFilter(terms, queryParts, params, withs, useFts);
tagFilter(terms, queryParts, params, relation, withs);
tagFilter(terms, queryParts, params, relation, withs, useFts);
resourceFilter(terms, queryParts, params, relation, withs);
resourceFilter(terms, queryParts, params, relation, withs, useFts);
textFilter(terms, queryParts, params, relation);
textFilter(terms, queryParts, params, relation, useFts);
typeFilter(terms, queryParts, params, relation);
typeFilter(terms, queryParts, params, relation, useFts);
completedFilter(terms, queryParts, params, relation);
completedFilter(terms, queryParts, params, relation, useFts);
dateFilter(terms, queryParts, params, relation);
dateFilter(terms, queryParts, params, relation, useFts);
locationFilter(terms, queryParts, params, relation);
locationFilter(terms, queryParts, params, relation, useFts);
sourceUrlFilter(terms, queryParts, params, relation);
sourceUrlFilter(terms, queryParts, params, relation, useFts);
let query;
if (withs.length > 0) {

View File

@@ -22,7 +22,7 @@ export default class ShareService {
}
public get enabled(): boolean {
return Setting.value('sync.target') === 9; // Joplin Server target
return [9, 10].includes(Setting.value('sync.target')); // Joplin Server, Joplin Cloud targets
}
private get store(): Store<any> {
@@ -36,10 +36,13 @@ export default class ShareService {
private api(): JoplinServerApi {
if (this.api_) return this.api_;
const syncTargetId = Setting.value('sync.target');
this.api_ = new JoplinServerApi({
baseUrl: () => Setting.value('sync.9.path'),
username: () => Setting.value('sync.9.username'),
password: () => Setting.value('sync.9.password'),
baseUrl: () => Setting.value(`sync.${syncTargetId}.path`),
userContentBaseUrl: () => Setting.value(`sync.${syncTargetId}.userContentPath`),
username: () => Setting.value(`sync.${syncTargetId}.username`),
password: () => Setting.value(`sync.${syncTargetId}.password`),
});
return this.api_;
@@ -134,7 +137,7 @@ export default class ShareService {
}
public shareUrl(share: StateShare): string {
return `${this.api().baseUrl()}/shares/${share.id}`;
return `${this.api().userContentBaseUrl()}/shares/${share.id}`;
}
public get shares() {

View File

@@ -1,7 +1,6 @@
import Setting from '../../models/Setting';
import { allNotesFolders, remoteNotesAndFolders, localNotesFoldersSameAsRemote } from '../../testing/test-utils-synchronizer';
const { syncTargetName, afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, synchronizer, sleep, switchClient, syncTargetId, fileApi } = require('../../testing/test-utils.js');
import { syncTargetName, afterAllCleanUp, synchronizerStart, setupDatabaseAndSynchronizer, synchronizer, sleep, switchClient, syncTargetId, fileApi } from '../../testing/test-utils';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import BaseItem from '../../models/BaseItem';

View File

@@ -1,15 +1,13 @@
import time from '../../time';
import shim from '../../shim';
import Setting from '../../models/Setting';
const { synchronizerStart, allSyncTargetItemsEncrypted, kvStore, supportDir, setupDatabaseAndSynchronizer, synchronizer, fileApi, switchClient, encryptionService, loadEncryptionMasterKey, decryptionWorker, checkThrowAsync } = require('../../testing/test-utils.js');
import { createFolderTree, syncTargetName, synchronizerStart, allSyncTargetItemsEncrypted, kvStore, supportDir, setupDatabaseAndSynchronizer, synchronizer, fileApi, switchClient, encryptionService, loadEncryptionMasterKey, decryptionWorker, checkThrowAsync } from '../../testing/test-utils';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import Resource from '../../models/Resource';
import ResourceFetcher from '../../services/ResourceFetcher';
import MasterKey from '../../models/MasterKey';
import BaseItem from '../../models/BaseItem';
import { createFolderTree } from '../../testing/test-utils';
let insideBeforeEach = false;
@@ -415,6 +413,13 @@ describe('Synchronizer.e2ee', function() {
}));
it('should not encrypt items that are shared by folder', (async () => {
// We skip this test for Joplin Server because it's going to check if
// the share_id refers to an existing share.
if (syncTargetName() === 'joplinServer') {
expect(true).toBe(true);
return;
}
Setting.setValue('encryption.enabled', true);
await loadEncryptionMasterKey();

View File

@@ -487,13 +487,12 @@ function shimInit(sharp = null, keytar = null, React = null, appVersion = null)
maxSockets: 1,
keepAliveMsecs: 5000,
};
if (url.startsWith('https')) {
shim.httpAgent_ = new https.Agent(AgentSettings);
} else {
shim.httpAgent_ = new http.Agent(AgentSettings);
}
shim.httpAgent_ = {
http: new http.Agent(AgentSettings),
https: new https.Agent(AgentSettings),
};
}
return shim.httpAgent_;
return url.startsWith('https') ? shim.httpAgent_.https : shim.httpAgent_.http;
};
shim.openOrCreateFile = (filepath, defaultContents) => {

View File

@@ -50,7 +50,8 @@ const WebDavApi = require('../WebDavApi');
const DropboxApi = require('../DropboxApi');
import JoplinServerApi from '../JoplinServerApi';
import { FolderEntity } from '../services/database/types';
import { credentialFile } from '../utils/credentialFiles';
import { credentialFile, readCredentialFile } from '../utils/credentialFiles';
import SyncTargetJoplinCloud from '../SyncTargetJoplinCloud';
const { loadKeychainServiceAndSettings } = require('../services/SettingUtils');
const md5 = require('md5');
const S3 = require('aws-sdk/clients/s3');
@@ -112,6 +113,7 @@ SyncTargetRegistry.addClass(SyncTargetNextcloud);
SyncTargetRegistry.addClass(SyncTargetDropbox);
SyncTargetRegistry.addClass(SyncTargetAmazonS3);
SyncTargetRegistry.addClass(SyncTargetJoplinServer);
SyncTargetRegistry.addClass(SyncTargetJoplinCloud);
let syncTargetName_ = '';
let syncTargetId_: number = null;
@@ -433,7 +435,8 @@ async function synchronizerStart(id: number = null, extraOptions: any = null) {
if (id === null) id = currentClient_;
const contextKey = `sync.${syncTargetId()}.context`;
const context = Setting.value(contextKey);
const contextString = Setting.value(contextKey);
const context = contextString ? JSON.parse(contextString) : {};
const options = Object.assign({}, extraOptions);
if (context) options.context = context;
@@ -569,13 +572,23 @@ async function initFileApi() {
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('joplinServer')) {
mustRunInBand();
const joplinServerAuth = JSON.parse(await readCredentialFile('joplin-server-test-units-2.json'));
// const joplinServerAuth = {
// "email": "admin@localhost",
// "password": "admin",
// "baseUrl": "http://api-joplincloud.local:22300",
// "userContentBaseUrl": ""
// }
// Note that to test the API in parallel mode, you need to use Postgres
// as database, as the SQLite database is not reliable when being
// read/write from multiple processes at the same time.
const api = new JoplinServerApi({
baseUrl: () => 'http://localhost:22300',
username: () => 'admin@localhost',
password: () => 'admin',
baseUrl: () => joplinServerAuth.baseUrl,
userContentBaseUrl: () => joplinServerAuth.userContentBaseUrl,
username: () => joplinServerAuth.email,
password: () => joplinServerAuth.password,
});
fileApi = new FileApi('', new FileApiDriverJoplinServer(api));

View File

@@ -28,7 +28,10 @@ export async function readCredentialFile(filename: string, defaultValue: string
try {
const filePath = await credentialFile(filename);
const r = await fs.readFile(filePath);
return r.toString();
// There's normally no reason to keep the last new line character and it
// can cause problems in certain scripts, so trim it. Any other white
// space should also not be relevant.
return r.toString().trim();
} catch (error) {
return defaultValue;
}

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

@@ -1,6 +1,6 @@
{
"name": "@joplin/server",
"version": "2.0.5",
"version": "2.0.6",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -901,6 +901,14 @@
}
}
},
"@koa/cors": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@koa/cors/-/cors-3.1.0.tgz",
"integrity": "sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q==",
"requires": {
"vary": "^1.1.2"
}
},
"@rmp135/sql-ts": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@rmp135/sql-ts/-/sql-ts-1.7.0.tgz",
@@ -1386,8 +1394,7 @@
"@types/node": {
"version": "12.7.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.5.tgz",
"integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w==",
"dev": true
"integrity": "sha512-9fq4jZVhPNW8r+UYKnxF1e2HkDWOWKM5bC2/7c9wPV835I0aOrVbS/Hw/pWPk2uKrNXQqg9Z959Kz+IYDd5p3w=="
},
"@types/nodemailer": {
"version": "6.4.1",
@@ -2034,6 +2041,11 @@
"resolved": "https://registry.npmjs.org/bulma-prefers-dark/-/bulma-prefers-dark-0.1.0-beta.0.tgz",
"integrity": "sha512-EeDW8pQrkYEOXo2l3WykfghbUzi8jlQWGI+Cu2HwmXwQHMcoGF6yiKYCNShttN+8z3atq8fLWh3B7pqXUV4fBA=="
},
"bytes": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
"integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
},
"cache-base": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz",
@@ -2098,6 +2110,15 @@
}
}
},
"call-bind": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
"requires": {
"function-bind": "^1.1.1",
"get-intrinsic": "^1.0.2"
}
},
"callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -3222,8 +3243,7 @@
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"gauge": {
"version": "2.7.4",
@@ -3246,6 +3266,16 @@
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true
},
"get-intrinsic": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
"integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
"requires": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.1"
}
},
"get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -3410,7 +3440,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dev": true,
"requires": {
"function-bind": "^1.1.1"
}
@@ -3420,6 +3449,11 @@
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
},
"has-symbols": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw=="
},
"has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@@ -6335,6 +6369,11 @@
}
}
},
"object-inspect": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz",
"integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw=="
},
"object-visit": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
@@ -6869,6 +6908,44 @@
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"dev": true
},
"raw-body": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz",
"integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==",
"requires": {
"bytes": "3.1.0",
"http-errors": "1.7.3",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"dependencies": {
"http-errors": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz",
"integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==",
"requires": {
"depd": "~1.1.2",
"inherits": "2.0.4",
"setprototypeof": "1.1.1",
"statuses": ">= 1.5.0 < 2",
"toidentifier": "1.0.0"
}
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
},
"setprototypeof": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
}
}
},
"rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -7261,6 +7338,16 @@
"dev": true,
"optional": true
},
"side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
"requires": {
"call-bind": "^1.0.0",
"get-intrinsic": "^1.0.2",
"object-inspect": "^1.9.0"
}
},
"signal-exit": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
@@ -7647,6 +7734,25 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
},
"stripe": {
"version": "8.150.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-8.150.0.tgz",
"integrity": "sha512-48YMLupzvDyVZUs37xUBd1SF0E3B77ahOTLhL7ycVwZqwjlQ30K7iHTejIAUdtEnWaNkaOz0LX6jHeR49IulRQ==",
"requires": {
"@types/node": ">=8.1.0",
"qs": "^6.6.0"
},
"dependencies": {
"qs": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz",
"integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==",
"requires": {
"side-channel": "^1.0.4"
}
}
}
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -7956,6 +8062,11 @@
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="
},
"unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
},
"unset-value": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/server",
"version": "2.0.5",
"version": "2.0.6",
"private": true,
"scripts": {
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
@@ -17,6 +17,7 @@
"@fortawesome/fontawesome-free": "^5.15.1",
"@joplin/lib": "^1.0.9",
"@joplin/renderer": "^1.7.4",
"@koa/cors": "^3.1.0",
"bcryptjs": "^2.4.3",
"bulma": "^0.9.1",
"bulma-prefers-dark": "^0.1.0-beta.0",
@@ -29,14 +30,16 @@
"markdown-it": "^12.0.4",
"mustache": "^3.1.0",
"nanoid": "^2.1.1",
"node-cron": "^3.0.0",
"node-env-file": "^0.1.8",
"nodemailer": "^6.6.0",
"nodemon": "^2.0.6",
"pg": "^8.5.1",
"pretty-bytes": "^5.6.0",
"query-string": "^6.8.3",
"raw-body": "^2.4.1",
"sqlite3": "^4.1.0",
"node-cron": "^3.0.0",
"stripe": "^8.150.0",
"yargs": "^14.0.0"
},
"devDependencies": {

View File

@@ -49,8 +49,6 @@ table.table th .sort-button i {
background-color: transparent;
}
.footer .container {
display: flex;
justify-content: flex-end;
.footer .content {
opacity: 0.5;
}

Binary file not shown.

View File

@@ -16,14 +16,16 @@ import ownerHandler from './middleware/ownerHandler';
import setupAppContext from './utils/setupAppContext';
import { initializeJoplinUtils } from './utils/joplinUtils';
import startServices from './utils/startServices';
import { credentialFile } from './utils/testing/testUtils';
const cors = require('@koa/cors');
const nodeEnvFile = require('node-env-file');
const { shimInit } = require('@joplin/lib/shim-init-node.js');
shimInit();
const env: Env = argv.env as Env || Env.Prod;
const envVariables: Record<Env, EnvVariables> = {
const defaultEnvVariables: Record<Env, EnvVariables> = {
dev: {
SQLITE_DATABASE: `${sqliteDefaultDir}/db-dev.sqlite`,
},
@@ -44,16 +46,6 @@ function appLogger(): LoggerWrapper {
return appLogger_;
}
const app = new Koa();
// Note: the order of middlewares is important. For example, ownerHandler
// loads the user, which is then used by notificationHandler. And finally
// routeHandler uses data from both previous middlewares. It would be good to
// layout these dependencies in code but not clear how to do this.
app.use(ownerHandler);
app.use(notificationHandler);
app.use(routeHandler);
function markPasswords(o: Record<string, any>): Record<string, any> {
const output: Record<string, any> = {};
@@ -72,8 +64,7 @@ async function getEnvFilePath(env: Env, argv: any): Promise<string> {
if (argv.envFile) return argv.envFile;
if (env === Env.Dev) {
const envFilePath = `${require('os').homedir()}/joplin-credentials/server.env`;
if (await fs.pathExists(envFilePath)) return envFilePath;
return credentialFile('server.env');
}
return '';
@@ -84,12 +75,63 @@ async function main() {
if (envFilePath) nodeEnvFile(envFilePath);
if (!envVariables[env]) throw new Error(`Invalid env: ${env}`);
if (!defaultEnvVariables[env]) throw new Error(`Invalid env: ${env}`);
await initConfig({
...envVariables[env],
const envVariables: EnvVariables = {
...defaultEnvVariables[env],
...process.env,
});
};
const app = new Koa();
// app.use(async function responseTime(ctx:AppContext, next:Function) {
// const start = Date.now();
// await next();
// const ms = Date.now() - start;
// console.info('Response time', ms)
// //ctx.set('X-Response-Time', `${ms}ms`);
// });
// Note: the order of middlewares is important. For example, ownerHandler
// loads the user, which is then used by notificationHandler. And finally
// routeHandler uses data from both previous middlewares. It would be good to
// layout these dependencies in code but not clear how to do this.
const corsAllowedDomains = [
'https://joplinapp.org',
];
function acceptOrigin(origin: string): boolean {
const hostname = (new URL(origin)).hostname;
const userContentDomain = envVariables.USER_CONTENT_BASE_URL ? (new URL(envVariables.USER_CONTENT_BASE_URL)).hostname : '';
if (hostname === userContentDomain) return true;
const hostnameNoSub = hostname.split('.').slice(1).join('.');
if (hostnameNoSub === userContentDomain) return true;
if (corsAllowedDomains.indexOf(origin) === 0) return true;
return false;
}
app.use(cors({
// https://github.com/koajs/cors/issues/52#issuecomment-413887382
origin: (ctx: AppContext) => {
const origin = ctx.request.header.origin;
if (acceptOrigin(origin)) {
return origin;
} else {
// we can't return void, so let's return one of the valid domains
return corsAllowedDomains[0];
}
},
}));
app.use(ownerHandler);
app.use(notificationHandler);
app.use(routeHandler);
await initConfig(env, envVariables);
await fs.mkdirp(config().logDir);
await fs.mkdirp(config().tempDir);

View File

@@ -1,9 +1,11 @@
import { rtrimSlashes } from '@joplin/lib/path-utils';
import { Config, DatabaseConfig, DatabaseConfigClient, MailerConfig, RouteType } from './utils/types';
import { Config, DatabaseConfig, DatabaseConfigClient, Env, MailerConfig, RouteType, StripeConfig } from './utils/types';
import * as pathUtils from 'path';
import { readFile } from 'fs-extra';
export interface EnvVariables {
APP_NAME?: string;
APP_BASE_URL?: string;
USER_CONTENT_BASE_URL?: string;
API_BASE_URL?: string;
@@ -29,6 +31,15 @@ export interface EnvVariables {
// This must be the full path to the database file
SQLITE_DATABASE?: string;
STRIPE_SECRET_KEY?: string;
STRIPE_PUBLISHABLE_KEY?: string;
STRIPE_WEBHOOK_SECRET?: string;
SIGNUP_ENABLED?: string;
TERMS_ENABLED?: string;
ERROR_STACK_TRACES?: string;
}
let runningInDocker_: boolean = false;
@@ -84,6 +95,14 @@ function mailerConfigFromEnv(env: EnvVariables): MailerConfig {
};
}
function stripeConfigFromEnv(env: EnvVariables): StripeConfig {
return {
secretKey: env.STRIPE_SECRET_KEY || '',
publishableKey: env.STRIPE_PUBLISHABLE_KEY || '',
webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
};
}
function baseUrlFromEnv(env: any, appPort: number): string {
if (env.APP_BASE_URL) {
return rtrimSlashes(env.APP_BASE_URL);
@@ -103,7 +122,7 @@ async function readPackageJson(filePath: string): Promise<PackageJson> {
let config_: Config = null;
export async function initConfig(env: EnvVariables, overrides: any = null) {
export async function initConfig(envType: Env, env: EnvVariables, overrides: any = null) {
runningInDocker_ = !!env.RUNNING_IN_DOCKER;
const rootDir = pathUtils.dirname(__dirname);
@@ -116,7 +135,8 @@ export async function initConfig(env: EnvVariables, overrides: any = null) {
config_ = {
appVersion: packageJson.version,
appName: 'Joplin Server',
appName: env.APP_NAME || 'Joplin Server',
env: envType,
rootDir: rootDir,
viewDir: viewDir,
layoutDir: `${viewDir}/layouts`,
@@ -124,10 +144,14 @@ export async function initConfig(env: EnvVariables, overrides: any = null) {
logDir: `${rootDir}/logs`,
database: databaseConfigFromEnv(runningInDocker_, env),
mailer: mailerConfigFromEnv(env),
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',
termsEnabled: env.TERMS_ENABLED === '1',
...overrides,
};
}

View File

@@ -394,6 +394,17 @@ export interface Token extends WithDates {
user_id?: Uuid;
}
export interface Subscription {
id?: number;
user_id?: Uuid;
stripe_user_id?: Uuid;
stripe_subscription_id?: Uuid;
last_payment_time?: number;
last_payment_failed_time?: number;
updated_time?: string;
created_time?: string;
}
export const databaseSchema: DatabaseTables = {
users: {
id: { type: 'string' },
@@ -534,5 +545,15 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
subscriptions: {
id: { type: 'number' },
user_id: { type: 'string' },
stripe_user_id: { type: 'string' },
stripe_subscription_id: { type: 'string' },
last_payment_time: { type: 'string' },
last_payment_failed_time: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
};
// AUTO-GENERATED-TYPES

View File

@@ -1,9 +1,10 @@
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) {
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
const requestStartTime = Date.now();
try {
const responseObject = await execRequest(ctx.routes, ctx);
@@ -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,
},
};
@@ -56,5 +58,10 @@ export default async function(ctx: AppContext) {
if (error.code) r.code = error.code;
ctx.response.body = r;
}
} finally {
// Technically this is not the total request duration because there are
// other middlewares but that should give a good approximation
const requestDuration = Date.now() - requestStartTime;
ctx.appLogger().info(`${ctx.request.method} ${ctx.path} (${requestDuration}ms)`);
}
}

View File

@@ -0,0 +1,19 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('subscriptions', function(table: Knex.CreateTableBuilder) {
table.increments('id').unique().primary().notNullable();
table.string('user_id', 32).notNullable();
table.string('stripe_user_id', 64).notNullable();
table.string('stripe_subscription_id', 64).notNullable();
table.bigInteger('last_payment_time').notNullable();
table.bigInteger('last_payment_failed_time').defaultTo(0).notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
}
export async function down(_db: DbConnection): Promise<any> {
}

View File

@@ -4,6 +4,7 @@ import uuidgen from '../utils/uuidgen';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
import { Models } from './factory';
import * as EventEmitter from 'events';
import { Config } from '../utils/types';
export interface SaveOptions {
isNew?: boolean;
@@ -41,13 +42,13 @@ export default abstract class BaseModel<T> {
private db_: DbConnection;
private transactionHandler_: TransactionHandler;
private modelFactory_: Function;
private baseUrl_: string;
private static eventEmitter_: EventEmitter = null;
private config_: Config;
public constructor(db: DbConnection, modelFactory: Function, baseUrl: string) {
public constructor(db: DbConnection, modelFactory: Function, config: Config) {
this.db_ = db;
this.modelFactory_ = modelFactory;
this.baseUrl_ = baseUrl;
this.config_ = config;
this.transactionHandler_ = new TransactionHandler(db);
}
@@ -56,11 +57,19 @@ export default abstract class BaseModel<T> {
// connection is passed to it. That connection can be the regular db
// connection, or the active transaction.
protected models(db: DbConnection = null): Models {
return this.modelFactory_(db || this.db);
return this.modelFactory_(db || this.db, this.config_);
}
protected get baseUrl(): string {
return this.baseUrl_;
return this.config_.baseUrl;
}
protected get userContentUrl(): string {
return this.config_.userContentBaseUrl;
}
protected get appName(): string {
return this.config_.appName;
}
protected get db(): DbConnection {
@@ -269,6 +278,11 @@ export default abstract class BaseModel<T> {
return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);
}
public async exists(id: string): Promise<boolean> {
const o = await this.load(id, { fields: ['id'] });
return !!o;
}
public async load(id: string, options: LoadOptions = {}): Promise<T> {
if (!id) throw new Error('id cannot be empty');

View File

@@ -3,7 +3,7 @@ import { ItemType, databaseSchema, Uuid, Item, ShareType, Share, ChangeType, Use
import { defaultPagination, paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
import { isJoplinItemName, isJoplinResourceBlobPath, linkedResourceIds, serializeJoplinItem, unserializeJoplinItem } from '../utils/joplinUtils';
import { ModelType } from '@joplin/lib/BaseModel';
import { ApiError, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../utils/errors';
import { ApiError, ErrorForbidden, ErrorUnprocessableEntity } from '../utils/errors';
import { Knex } from 'knex';
import { ChangePreviousItem } from './ChangeModel';
@@ -345,6 +345,10 @@ export default class ItemModel extends BaseModel<Item> {
if ('name' in item && !item.name) throw new ErrorUnprocessableEntity('name cannot be empty');
}
if (item.jop_share_id) {
if (!(await this.models().share().exists(item.jop_share_id))) throw new ErrorUnprocessableEntity(`share not found: ${item.jop_share_id}`);
}
return super.validate(item, options);
}
@@ -499,7 +503,7 @@ export default class ItemModel extends BaseModel<Item> {
public async deleteForUser(userId: Uuid, item: Item): Promise<void> {
if (this.isRootSharedFolder(item)) {
const share = await this.models().share().byItemId(item.id);
if (!share) throw new ErrorNotFound(`Cannot find share associated with item ${item.id}`);
if (!share) throw new Error(`Cannot find share associated with item ${item.id}`);
const userShare = await this.models().shareUser().byShareAndUserId(share.id, userId);
if (!userShare) return;
await this.models().shareUser().delete(userShare.id);

View File

@@ -15,25 +15,6 @@ interface NotificationType {
message: string;
}
const notificationTypes: Record<string, NotificationType> = {
[NotificationKey.ConfirmEmail]: {
level: NotificationLevel.Normal,
message: 'Welcome to Joplin Server! An email has been sent to you containing an activation link to complete your registration.',
},
[NotificationKey.EmailConfirmed]: {
level: NotificationLevel.Normal,
message: 'You email has been confirmed',
},
[NotificationKey.PasswordSet]: {
level: NotificationLevel.Normal,
message: 'Welcome to Joplin Server! Your password has been set successfully.',
},
[NotificationKey.UsingSqliteInProd]: {
level: NotificationLevel.Important,
message: 'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.',
},
};
export default class NotificationModel extends BaseModel<Notification> {
protected get tableName(): string {
@@ -49,6 +30,25 @@ export default class NotificationModel extends BaseModel<Notification> {
const n: Notification = await this.loadByKey(userId, key);
if (n) return n;
const notificationTypes: Record<string, NotificationType> = {
[NotificationKey.ConfirmEmail]: {
level: NotificationLevel.Normal,
message: `Welcome to ${this.appName}! An email has been sent to you containing an activation link to complete your registration.`,
},
[NotificationKey.EmailConfirmed]: {
level: NotificationLevel.Normal,
message: 'Your email has been confirmed',
},
[NotificationKey.PasswordSet]: {
level: NotificationLevel.Normal,
message: `Welcome to ${this.appName}! Your password has been set successfully.`,
},
[NotificationKey.UsingSqliteInProd]: {
level: NotificationLevel.Important,
message: 'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.',
},
};
const type = notificationTypes[key];
if (level === null) {

View File

@@ -80,7 +80,7 @@ export default class ShareModel extends BaseModel<Share> {
}
public shareUrl(id: Uuid, query: any = null): string {
return setQueryParameters(`${this.baseUrl}/shares/${id}`, query);
return setQueryParameters(`${this.userContentUrl}/shares/${id}`, query);
}
public async byItemId(itemId: Uuid): Promise<Share | null> {

View File

@@ -0,0 +1,40 @@
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../utils/testing/testUtils';
import { AccountType } from './UserModel';
import { MB } from '../utils/bytes';
describe('SubscriptionModel', function() {
beforeAll(async () => {
await beforeAllDb('SubscriptionModel');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should create a user and subscription', async function() {
await models().subscription().saveUserAndSubscription(
'toto@example.com',
AccountType.Pro,
'STRIPE_USER_ID',
'STRIPE_SUB_ID'
);
const user = await models().user().loadByEmail('toto@example.com');
const sub = await models().subscription().byStripeSubscriptionId('STRIPE_SUB_ID');
expect(user.account_type).toBe(AccountType.Pro);
expect(user.email).toBe('toto@example.com');
expect(user.can_share).toBe(1);
expect(user.max_item_size).toBe(200 * MB);
expect(sub.stripe_subscription_id).toBe('STRIPE_SUB_ID');
expect(sub.stripe_user_id).toBe('STRIPE_USER_ID');
expect(sub.user_id).toBe(user.id);
});
});

View File

@@ -0,0 +1,72 @@
import { EmailSender, Subscription, Uuid } from '../db';
import { ErrorNotFound } from '../utils/errors';
import uuidgen from '../utils/uuidgen';
import BaseModel from './BaseModel';
import { AccountType, accountTypeProperties } from './UserModel';
export default class SubscriptionModel extends BaseModel<Subscription> {
public get tableName(): string {
return 'subscriptions';
}
protected hasUuid(): boolean {
return false;
}
public async handlePayment(subscriptionId: string, success: boolean) {
const sub = await this.byStripeSubscriptionId(subscriptionId);
if (!sub) throw new ErrorNotFound(`No such subscription: ${subscriptionId}`);
const now = Date.now();
const toSave: Subscription = { id: sub.id };
if (success) {
toSave.last_payment_time = now;
} else {
toSave.last_payment_failed_time = now;
const user = await this.models().user().load(sub.user_id, { fields: ['email'] });
await this.models().email().push({
subject: `${this.appName} subscription payment failed`,
body: `Your invoice payment has failed. Please follow this URL to update your payment details: \n\n[Manage your subscription](${this.baseUrl}/portal)`,
recipient_email: user.email,
sender_id: EmailSender.Support,
});
}
await this.save(toSave);
}
public async byStripeSubscriptionId(id: string): Promise<Subscription> {
return this.db(this.tableName).select(this.defaultFields).where('stripe_subscription_id', '=', id).first();
}
public async byUserId(userId: Uuid): Promise<Subscription> {
return this.db(this.tableName).select(this.defaultFields).where('user_id', '=', userId).first();
}
public async saveUserAndSubscription(email: string, accountType: AccountType, stripeUserId: string, stripeSubscriptionId: string) {
return this.withTransaction(async () => {
const user = await this.models().user().save({
...accountTypeProperties(accountType),
email,
email_confirmed: 1,
password: uuidgen(),
must_set_password: 1,
});
const subscription = await this.save({
user_id: user.id,
stripe_user_id: stripeUserId,
stripe_subscription_id: stripeSubscriptionId,
last_payment_time: Date.now(),
});
return { user, subscription };
});
}
}

View File

@@ -4,7 +4,41 @@ import * as auth from '../utils/auth';
import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge, ErrorNotFound } from '../utils/errors';
import { ModelType } from '@joplin/lib/BaseModel';
import { _ } from '@joplin/lib/locale';
import prettyBytes = require('pretty-bytes');
import { formatBytes, MB } from '../utils/bytes';
export enum AccountType {
Default = 0,
Free = 1,
Pro = 2,
}
interface AccountTypeProperties {
account_type: number;
can_share: number;
max_item_size: number;
}
export function accountTypeProperties(accountType: AccountType): AccountTypeProperties {
const types: AccountTypeProperties[] = [
{
account_type: AccountType.Default,
can_share: 1,
max_item_size: 0,
},
{
account_type: AccountType.Free,
can_share: 0,
max_item_size: 10 * MB,
},
{
account_type: AccountType.Pro,
can_share: 1,
max_item_size: 200 * MB,
},
];
return types.find(a => a.account_type === accountType);
}
export default class UserModel extends BaseModel<User> {
@@ -85,7 +119,7 @@ export default class UserModel extends BaseModel<User> {
throw new ErrorPayloadTooLarge(_('Cannot save %s "%s" because it is larger than than the allowed limit (%s)',
isNote ? _('note') : _('attachment'),
itemTitle ? itemTitle : name,
prettyBytes(user.max_item_size)
formatBytes(user.max_item_size)
));
}
}
@@ -188,11 +222,13 @@ export default class UserModel extends BaseModel<User> {
recipient_id: savedUser.id,
recipient_email: savedUser.email,
recipient_name: savedUser.full_name || '',
subject: 'Please setup your Joplin account',
body: `Your new Joplin account has been created!\n\nPlease click on the following link to complete the creation of your account:\n\n${confirmUrl}`,
subject: `Please setup your ${this.appName} account`,
body: `Your new ${this.appName} account has been created!\n\nPlease click on the following link to complete the creation of your account:\n\n[Complete your account](${confirmUrl})`,
});
}
UserModel.eventEmitter.emit('created');
return savedUser;
});
}

View File

@@ -68,71 +68,77 @@ import ItemResourceModel from './ItemResourceModel';
import ShareUserModel from './ShareUserModel';
import KeyValueModel from './KeyValueModel';
import TokenModel from './TokenModel';
import SubscriptionModel from './SubscriptionModel';
import { Config } from '../utils/types';
export class Models {
private db_: DbConnection;
private baseUrl_: string;
private config_: Config;
public constructor(db: DbConnection, baseUrl: string) {
public constructor(db: DbConnection, config: Config) {
this.db_ = db;
this.baseUrl_ = baseUrl;
this.config_ = config;
}
public item() {
return new ItemModel(this.db_, newModelFactory, this.baseUrl_);
return new ItemModel(this.db_, newModelFactory, this.config_);
}
public user() {
return new UserModel(this.db_, newModelFactory, this.baseUrl_);
return new UserModel(this.db_, newModelFactory, this.config_);
}
public email() {
return new EmailModel(this.db_, newModelFactory, this.baseUrl_);
return new EmailModel(this.db_, newModelFactory, this.config_);
}
public userItem() {
return new UserItemModel(this.db_, newModelFactory, this.baseUrl_);
return new UserItemModel(this.db_, newModelFactory, this.config_);
}
public token() {
return new TokenModel(this.db_, newModelFactory, this.baseUrl_);
return new TokenModel(this.db_, newModelFactory, this.config_);
}
public itemResource() {
return new ItemResourceModel(this.db_, newModelFactory, this.baseUrl_);
return new ItemResourceModel(this.db_, newModelFactory, this.config_);
}
public apiClient() {
return new ApiClientModel(this.db_, newModelFactory, this.baseUrl_);
return new ApiClientModel(this.db_, newModelFactory, this.config_);
}
public session() {
return new SessionModel(this.db_, newModelFactory, this.baseUrl_);
return new SessionModel(this.db_, newModelFactory, this.config_);
}
public change() {
return new ChangeModel(this.db_, newModelFactory, this.baseUrl_);
return new ChangeModel(this.db_, newModelFactory, this.config_);
}
public notification() {
return new NotificationModel(this.db_, newModelFactory, this.baseUrl_);
return new NotificationModel(this.db_, newModelFactory, this.config_);
}
public share() {
return new ShareModel(this.db_, newModelFactory, this.baseUrl_);
return new ShareModel(this.db_, newModelFactory, this.config_);
}
public shareUser() {
return new ShareUserModel(this.db_, newModelFactory, this.baseUrl_);
return new ShareUserModel(this.db_, newModelFactory, this.config_);
}
public keyValue() {
return new KeyValueModel(this.db_, newModelFactory, this.baseUrl_);
return new KeyValueModel(this.db_, newModelFactory, this.config_);
}
public subscription() {
return new SubscriptionModel(this.db_, newModelFactory, this.config_);
}
}
export default function newModelFactory(db: DbConnection, baseUrl: string): Models {
return new Models(db, baseUrl);
export default function newModelFactory(db: DbConnection, config: Config): Models {
return new Models(db, config);
}

View File

@@ -1,3 +1,4 @@
import config from '../../config';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
@@ -6,7 +7,7 @@ const router = new Router(RouteType.Api);
router.public = true;
router.get('api/ping', async () => {
return { status: 'ok', message: 'Joplin Server is running' };
return { status: 'ok', message: `${config().appName} is running` };
});
export default router;

View File

@@ -10,7 +10,7 @@ import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
import { PaginationOrderDir } from '../../models/utils/pagination';
const prettyBytes = require('pretty-bytes');
import { formatBytes } from '../../utils/bytes';
const router = new Router(RouteType.Web);
@@ -50,7 +50,7 @@ router.get('items', async (_path: SubPath, ctx: AppContext) => {
url: showItemUrls(config()) ? `${config().userContentBaseUrl}/items/${item.id}/content` : null,
},
{
value: prettyBytes(item.content_size),
value: formatBytes(item.content_size),
},
{
value: item.mime_type || 'binary',

View File

@@ -1,4 +1,4 @@
import { SubPath, redirect } from '../../utils/routeUtils';
import { SubPath, redirect, makeUrl, UrlType } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
@@ -9,8 +9,10 @@ import { View } from '../../services/MustacheService';
function makeView(error: any = null): View {
const view = defaultView('login');
view.content.error = error;
view.navbar = false;
view.content = {
error,
signupUrl: config().signupEnabled ? makeUrl(UrlType.Signup) : '',
};
return view;
}

View File

@@ -0,0 +1,61 @@
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import MarkdownIt = require('markdown-it');
const router: Router = new Router(RouteType.Web);
router.public = true;
router.get('privacy', async (_path: SubPath, _ctx: AppContext) => {
const markdownIt = new MarkdownIt();
return markdownIt.render(`# Joplin Cloud Privacy Policy
## Who are we?
The Joplin Cloud web service is owned by Cozid Ltd, registered in England and Wales.
## What information do we collect?
In order to operate this service, the following user data is stored:
- Email (required)
- Full name (optional)
- Joplin synchronisation items.
- Stripe user ID
- Stripe subscription ID
Financial information is processed by Stripe. We do not store financial information.
## How do we use personal information?
We use the email to authenticate the user and allow them to login to the service and synchronise data with it. We also use it to send important emails, such as email verification or to recover a lost password. Occasionally, we may send important notifications to our users.
## What legal basis do we have for processing your personal data?
GDPR applies.
## When do we share personal data?
We treat personal data confidentially and will not share it with any third party.
## Where do we store and process personal data?
Personal data is stored securely in a Postgres database. Access to it is strictly controlled.
We may process the data for reporting purposes - for example, how many users use the service. How many requests per day, etc.
## How do we secure personal data?
A backup is made at regular intervals and stored on a secure server.
## How long do we keep your personal data for?
We keep your data for as long as you use the service. If you would like to stop using it and have your data deleted, please contact us. We will also consider automatic data deletion provided it can be done in a safe way.
## How to contact us?
Please contact us at [team@joplincloud.com](mailto:team@joplincloud.com) for any question.`);
});
export default router;

View File

@@ -40,6 +40,6 @@ router.get('shares/:id', async (path: SubPath, ctx: AppContext) => {
ctx.response.set('Content-Type', result.mime);
ctx.response.set('Content-Length', result.size.toString());
return new Response(ResponseType.KoaResponse, ctx.response);
});
}, RouteType.UserContent);
export default router;

View File

@@ -1,6 +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() {
@@ -17,17 +21,30 @@ 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');
expect(user).toBeTruthy();
expect(user.account_type).toBe(AccountType.Free);
expect(user.email_confirmed).toBe(0);
expect(user.can_share).toBe(0);
expect(user.max_item_size).toBe(10 * MB);
// Check that the user is logged in
const session = await models().session().load(context.cookies.get('sessionId'));

View File

@@ -1,4 +1,4 @@
import { SubPath, redirect } from '../../utils/routeUtils';
import { SubPath, redirect, makeUrl, UrlType } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
@@ -8,16 +8,20 @@ import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import { checkPassword } from './users';
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType, accountTypeProperties } from '../../models/UserModel';
import { ErrorForbidden } from '../../utils/errors';
function makeView(error: Error = null): View {
const view = defaultView('signup');
view.content.error = error;
view.content.postUrl = `${config().baseUrl}/signup`;
view.navbar = false;
view.content = {
error,
postUrl: makeUrl(UrlType.Signup),
loginUrl: makeUrl(UrlType.Login),
};
return view;
}
interface FormUser {
export interface FormUser {
full_name: string;
email: string;
password: string;
@@ -33,11 +37,14 @@ router.get('signup', async (_path: SubPath, _ctx: AppContext) => {
});
router.post('signup', async (_path: SubPath, ctx: AppContext) => {
if (!config().signupEnabled) throw new ErrorForbidden('Signup is not enabled');
try {
const formUser = await bodyFields<FormUser>(ctx.req);
const password = checkPassword(formUser, true);
const user = await ctx.models.user().save({
...accountTypeProperties(AccountType.Free),
email: formUser.email,
full_name: formUser.full_name,
password,

View File

@@ -0,0 +1,289 @@
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType, StripeConfig } from '../../utils/types';
import { AppContext } from '../../utils/types';
import { bodyFields } from '../../utils/requestUtils';
import globalConfig from '../../config';
import { ErrorForbidden, ErrorNotFound } from '../../utils/errors';
import { Stripe } from 'stripe';
import Logger from '@joplin/lib/Logger';
import getRawBody = require('raw-body');
import { AccountType } from '../../models/UserModel';
const stripeLib = require('stripe');
const logger = Logger.create('/stripe');
const router: Router = new Router(RouteType.Web);
router.public = true;
function stripeConfig(): StripeConfig {
return globalConfig().stripe;
}
function initStripe(): Stripe {
return stripeLib(stripeConfig().secretKey);
}
async function stripeEvent(stripe: Stripe, req: any): Promise<Stripe.Event> {
if (!stripeConfig().webhookSecret) throw new Error('webhookSecret is required');
const body = await getRawBody(req);
return stripe.webhooks.constructEvent(
body,
req.headers['stripe-signature'],
stripeConfig().webhookSecret
);
}
interface CreateCheckoutSessionFields {
priceId: string;
}
type StripeRouteHandler = (stripe: Stripe, path: SubPath, ctx: AppContext)=> Promise<any>;
const postHandlers: Record<string, StripeRouteHandler> = {
createCheckoutSession: async (stripe: Stripe, __path: SubPath, ctx: AppContext) => {
const fields = await bodyFields<CreateCheckoutSessionFields>(ctx.req);
const priceId = fields.priceId;
// See https://stripe.com/docs/api/checkout/sessions/create
// for additional parameters to pass.
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
// For metered billing, do not pass quantity
quantity: 1,
},
],
// {CHECKOUT_SESSION_ID} is a string literal; do not change it!
// the actual Session ID is returned in the query parameter when your customer
// is redirected to the success page.
success_url: `${globalConfig().baseUrl}/stripe/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${globalConfig().baseUrl}/stripe/cancel`,
});
return {
sessionId: session.id,
};
},
webhook: async (stripe: Stripe, _path: SubPath, ctx: AppContext) => {
const event = await stripeEvent(stripe, ctx.req);
const hooks: any = {
'checkout.session.completed': async () => {
// Payment is successful and the subscription is created.
//
// For testing: `stripe trigger checkout.session.completed`
// Or use /checkoutTest URL.
// {
// "object": {
// "id": "cs_test_xxxxxxxxxxxxxxxxxx",
// "object": "checkout.session",
// "allow_promotion_codes": null,
// "amount_subtotal": 499,
// "amount_total": 499,
// "billing_address_collection": null,
// "cancel_url": "http://joplincloud.local:22300/stripe/cancel",
// "client_reference_id": null,
// "currency": "gbp",
// "customer": "cus_xxxxxxxxxxxx",
// "customer_details": {
// "email": "toto@example.com",
// "tax_exempt": "none",
// "tax_ids": [
// ]
// },
// "customer_email": null,
// "livemode": false,
// "locale": null,
// "metadata": {
// },
// "mode": "subscription",
// "payment_intent": null,
// "payment_method_options": {
// },
// "payment_method_types": [
// "card"
// ],
// "payment_status": "paid",
// "setup_intent": null,
// "shipping": null,
// "shipping_address_collection": null,
// "submit_type": null,
// "subscription": "sub_xxxxxxxxxxxxxxxx",
// "success_url": "http://joplincloud.local:22300/stripe/success?session_id={CHECKOUT_SESSION_ID}",
// "total_details": {
// "amount_discount": 0,
// "amount_shipping": 0,
// "amount_tax": 0
// }
// }
// }
const checkoutSession: Stripe.Checkout.Session = event.data.object as Stripe.Checkout.Session;
// The Stripe TypeScript object defines "customer" and
// "subscription" as various types but they are actually
// string according to the documentation.
const stripeUserId = checkoutSession.customer as string;
const stripeSubscriptionId = checkoutSession.subscription as string;
await ctx.models.subscription().saveUserAndSubscription(
checkoutSession.customer_details.email || checkoutSession.customer_email,
AccountType.Pro,
stripeUserId,
stripeSubscriptionId
);
},
'invoice.paid': async () => {
// Continue to provision the subscription as payments continue
// to be made. Store the status in your database and check when
// a user accesses your service. This approach helps you avoid
// hitting rate limits.
//
// Note that when the subscription is created, this event is
// going to be triggered before "checkout.session.completed" (at
// least in tests), which means it won't find the subscription
// at this point, but this is fine because the required data is
// saved in checkout.session.completed.
const invoice = event.data.object as Stripe.Invoice;
await ctx.models.subscription().handlePayment(invoice.subscription as string, true);
},
'invoice.payment_failed': async () => {
// The payment failed or the customer does not have a valid payment method.
// The subscription becomes past_due. Notify your customer and send them to the
// customer portal to update their payment information.
//
// For testing: `stripe trigger invoice.payment_failed`
const invoice = event.data.object as Stripe.Invoice;
const subId = invoice.subscription as string;
await ctx.models.subscription().handlePayment(subId, false);
},
};
if (hooks[event.type]) {
logger.info(`Got Stripe event: ${event.type} [Handled]`);
await hooks[event.type]();
} else {
logger.info(`Got Stripe event: ${event.type} [Unhandled]`);
}
},
};
const getHandlers: Record<string, StripeRouteHandler> = {
success: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
return `
<p>Thank you for signing up for ${globalConfig().appName} Pro! You should receive an email shortly with instructions on how to connect to your account.</p>
<p><a href="https://joplinapp.org">Go back to JoplinApp.org</a></p>
`;
},
cancel: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
return `
<p>Your payment has been cancelled.</p>
<p><a href="https://joplinapp.org">Go back to JoplinApp.org</a></p>
`;
},
portal: async (stripe: Stripe, _path: SubPath, ctx: AppContext) => {
if (!ctx.owner) throw new ErrorForbidden('Please login to access the subscription portal');
const sub = await ctx.models.subscription().byUserId(ctx.owner.id);
if (!sub) throw new ErrorNotFound('Could not find subscription');
const billingPortalSession = await stripe.billingPortal.sessions.create({ customer: sub.stripe_user_id as string });
return `
<html>
<head>
<meta http-equiv = "refresh" content = "1; url = ${billingPortalSession.url};" />
<script>setTimeout(() => { window.location.href = ${JSON.stringify(billingPortalSession.url)}; }, 2000)</script>
</head>
<body>
Redirecting to subscription portal...
</body>
</html>`;
},
checkoutTest: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
return `
<head>
<title>Checkout</title>
<script src="https://js.stripe.com/v3/"></script>
<script>
var stripe = Stripe(${JSON.stringify(stripeConfig().publishableKey)});
var createCheckoutSession = function(priceId) {
return fetch("/stripe/createCheckoutSession", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
priceId: priceId
})
}).then(function(result) {
return result.json();
});
};
</script>
</head>
<body>
<button id="checkout">Subscribe</button>
<script>
var PRICE_ID = 'price_1IvlmiLx4fybOTqJMKNZhLh2';
function handleResult() {
console.info('Redirected to checkout');
}
document
.getElementById("checkout")
.addEventListener("click", function(evt) {
evt.preventDefault();
// You'll have to define PRICE_ID as a price ID before this code block
createCheckoutSession(PRICE_ID).then(function(data) {
// Call Stripe.js method to redirect to the new Checkout page
stripe
.redirectToCheckout({
sessionId: data.sessionId
})
.then(handleResult);
});
});
</script>
</body>
`;
},
};
router.post('stripe/:id', async (path: SubPath, ctx: AppContext) => {
if (!postHandlers[path.id]) throw new ErrorNotFound(`No such action: ${path.id}`);
return postHandlers[path.id](initStripe(), path, ctx);
});
router.get('stripe/:id', async (path: SubPath, ctx: AppContext) => {
if (!getHandlers[path.id]) throw new ErrorNotFound(`No such action: ${path.id}`);
return getHandlers[path.id](initStripe(), path, ctx);
});
export default router;

Some files were not shown because too many files have changed in this diff Show More