You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-09-05 20:56:22 +02:00
Compare commits
51 Commits
server-v2.
...
v2.0.6
Author | SHA1 | Date | |
---|---|---|---|
|
d0c4de92e2 | ||
|
91ce465535 | ||
|
4098c01e7c | ||
|
e617e6fab3 | ||
|
5fd6571bf1 | ||
|
00dc1d881b | ||
|
c37eb56ed7 | ||
|
b2b6ad479a | ||
|
0e4c545e14 | ||
|
bbae1aef28 | ||
|
cf86ffc36e | ||
|
9d80a79cda | ||
|
ca487ade9a | ||
|
75b66a9fff | ||
|
56fdf97693 | ||
|
ce02a30441 | ||
|
a058e09183 | ||
|
594084e274 | ||
|
5614eb9442 | ||
|
7a3a2084db | ||
|
95d7ccccea | ||
|
f7a7009b3c | ||
|
de7579a14e | ||
|
c8d7ecbf6c | ||
|
3c41b45e8e | ||
|
62a371b9f3 | ||
|
5528ab7cc8 | ||
|
824afd4809 | ||
|
8ed1330d68 | ||
|
fec5d4b335 | ||
|
e7b9103bfc | ||
|
dd1c9e3c2a | ||
|
7c45b95f6f | ||
|
a7e67952b8 | ||
|
1b7d40387d | ||
|
7921e70c4f | ||
|
8afac643ba | ||
|
23cfbc2367 | ||
|
de45740129 | ||
|
a04d8ef441 | ||
|
db7b802803 | ||
|
75d79f373a | ||
|
e8a02c26d0 | ||
|
147b6b13ab | ||
|
a496a3d90d | ||
|
69a8ada2ec | ||
|
87257870f4 | ||
|
21ea3253db | ||
|
770af6a53b | ||
|
c88e4f6628 | ||
|
2f79492192 |
@@ -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
127
.github/scripts/run_ci.sh
vendored
Executable 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
|
37
.github/workflows/github-actions-main.yml
vendored
Normal file
37
.github/workflows/github-actions-main.yml
vendored
Normal 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
9
.gitignore
vendored
@@ -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
|
||||
|
138
.travis.yml
138
.travis.yml
@@ -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
|
BIN
Assets/WebsiteAssets/images/sponsors/Tranio.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/Tranio.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
2
BUILD.md
2
BUILD.md
@@ -1,4 +1,4 @@
|
||||
[](https://travis-ci.org/laurent22/joplin) [](https://ci.appveyor.com/project/laurent22/joplin)
|
||||
[](https://travis-ci.com/laurent22/joplin) [](https://ci.appveyor.com/project/laurent22/joplin)
|
||||
|
||||
# Building the applications
|
||||
|
||||
|
44
README.md
44
README.md
@@ -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> <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:
|
||||
|
||||
|
@@ -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">'editor.execCommand'</span>, {
|
||||
<span class="hljs-attr">name</span>: <span class="hljs-string">'madeUpCommand'</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">''</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>
|
||||
<!--
|
||||
|
@@ -149,10 +149,8 @@
|
||||
<p>Currently the supported context variables aren'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>
|
||||
|
@@ -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&business=E8JMYD2LQ8MMA&lc=GB&item_name=Joplin+Development&currency_code=EUR&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>
|
||||
|
@@ -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&business=E8JMYD2LQ8MMA&lc=GB&item_name=Joplin+Development&currency_code=EUR&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>
|
||||
|
BIN
docs/images/sponsors/Tranio.png
Normal file
BIN
docs/images/sponsors/Tranio.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
830
docs/index.html
830
docs/index.html
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
@@ -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>
|
||||
|
105
packages/app-cli/package-lock.json
generated
105
packages/app-cli/package-lock.json
generated
@@ -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
@@ -1 +1 @@
|
||||
$katexcode$
|
||||
Hello World:$katexcode$
|
File diff suppressed because one or more lines are too long
@@ -1,3 +1,5 @@
|
||||
Hello World :
|
||||
|
||||
$$
|
||||
katexcode
|
||||
\sqrt{3x}
|
||||
$$
|
@@ -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'),
|
||||
|
20
packages/app-desktop/commands/restoreNoteRevision.ts
Normal file
20
packages/app-desktop/commands/restoreNoteRevision.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
@@ -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',
|
||||
},
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
||||
|
@@ -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;
|
||||
|
@@ -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];
|
||||
});
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
@@ -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() {
|
||||
|
@@ -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>;
|
||||
|
@@ -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())
|
||||
|
2
packages/app-desktop/package-lock.json
generated
2
packages/app-desktop/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.6",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@@ -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,
|
||||
|
@@ -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) {
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -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 = [];
|
||||
|
@@ -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';
|
||||
|
10
packages/fork-sax/package-lock.json
generated
10
packages/fork-sax/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
},
|
||||
|
2
packages/generator-joplin/package-lock.json
generated
2
packages/generator-joplin/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "generator-joplin",
|
||||
"version": "1.8.1",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@@ -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
|
||||
|
@@ -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;
|
||||
|
59
packages/lib/SyncTargetJoplinCloud.ts
Normal file
59
packages/lib/SyncTargetJoplinCloud.ts
Normal 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'));
|
||||
}
|
||||
}
|
@@ -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() {
|
||||
|
@@ -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) {
|
||||
|
@@ -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)) {
|
||||
|
@@ -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');
|
||||
}
|
||||
}
|
||||
|
@@ -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
@@ -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 () => {
|
||||
|
@@ -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');
|
||||
|
@@ -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 },
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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() {
|
||||
|
@@ -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']),
|
||||
};
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
||||
|
@@ -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);
|
||||
|
@@ -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 () => {
|
||||
|
@@ -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}`);
|
||||
|
@@ -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);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
@@ -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
@@ -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) {
|
||||
|
@@ -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() {
|
||||
|
@@ -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';
|
||||
|
@@ -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();
|
||||
|
||||
|
@@ -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) => {
|
||||
|
@@ -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));
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
|
123
packages/server/package-lock.json
generated
123
packages/server/package-lock.json
generated
@@ -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",
|
||||
|
@@ -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": {
|
||||
|
@@ -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.
@@ -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);
|
||||
|
@@ -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,
|
||||
};
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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)`);
|
||||
}
|
||||
}
|
||||
|
19
packages/server/src/migrations/20210531150817_stripe.ts
Normal file
19
packages/server/src/migrations/20210531150817_stripe.ts
Normal 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> {
|
||||
|
||||
}
|
@@ -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');
|
||||
|
||||
|
@@ -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);
|
||||
|
@@ -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) {
|
||||
|
@@ -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> {
|
||||
|
40
packages/server/src/models/SubscriptionModel.test.ts
Normal file
40
packages/server/src/models/SubscriptionModel.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
72
packages/server/src/models/SubscriptionModel.ts
Normal file
72
packages/server/src/models/SubscriptionModel.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@@ -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;
|
||||
});
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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',
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
61
packages/server/src/routes/index/privacy.ts
Normal file
61
packages/server/src/routes/index/privacy.ts
Normal 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;
|
@@ -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;
|
||||
|
@@ -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'));
|
||||
|
@@ -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,
|
||||
|
289
packages/server/src/routes/index/stripe.ts
Normal file
289
packages/server/src/routes/index/stripe.ts
Normal 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
Reference in New Issue
Block a user