1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-01-02 00:08:04 +02:00

Compare commits

...

35 Commits

Author SHA1 Message Date
Laurent Cozic
b611b441c5 tests 2021-11-30 12:03:20 +00:00
Laurent Cozic
1962f03adf tests 2021-11-30 11:25:27 +00:00
Laurent Cozic
66fa23cdd8 tests 2021-11-30 11:15:42 +00:00
Laurent Cozic
6eec0dd58e Server v2.6.13 2021-11-29 18:41:53 +00:00
Laurent Cozic
dc67eace24 Server: Allow disabling NTP time drift check 2021-11-29 18:39:07 +00:00
Laurent Cozic
4fecb083a7 Server: Optimise reading from external storage when fallback driver is not present 2021-11-29 18:28:39 +00:00
Laurent Cozic
ef23d99b47 Added tests 2021-11-29 17:51:41 +00:00
Laurent Cozic
01048f5971 Server: Added command to delete database item content 2021-11-29 17:27:40 +00:00
Laurent Cozic
06ce4adc20 iOS: Resolves #5705 (partially): Ping joplinapp.org domain instead of Google when doing the WiFi connection check 2021-11-29 12:51:11 +00:00
Laurent Cozic
2fd8f39293 Update clipper button text 2021-11-29 11:24:34 +00:00
Laurent Cozic
3627fa14e1 Clipper: Fixes #5675: Notebook selection list could be too wide 2021-11-29 11:14:41 +00:00
Laurent Cozic
6950c40b12 Android: Fixed opening attachments 2021-11-29 10:37:06 +00:00
Laurent Cozic
a6884a2ee4 Server: Remove unique constraint errors from the log when they are already handled by the application 2021-11-29 10:17:13 +00:00
Laurent Cozic
7eb1d89d66 Server: Retry NTP request up to three times when it fails 2021-11-29 10:17:12 +00:00
MovingEarth
920847245f Server: Do not set the SMTP auth option when user or password are not set (#5791) 2021-11-28 19:45:07 +00:00
Laurent Cozic
605f12552e All: Fixes #5796: Handle duplicate attachments when the parent notebook is shared 2021-11-28 16:46:44 +00:00
Laurent
0689db48de Doc: Added GSoC idea: Implement default plugins on desktop application 2021-11-28 15:16:36 +00:00
Roman Musin
f224282a27 Android: Fixes #5216: Alarms were not being triggered in some cases (#5798) 2021-11-27 16:22:30 +00:00
Laurent Cozic
c0a8c330a9 All: Also duplicate resources when duplicating a note
Ref: https://github.com/laurent22/joplin/issues/5796
2021-11-27 16:05:28 +00:00
Laurent Cozic
4ce58fa486 Mobile: Fixes #5777: Alarm setting buttons were no longer visible 2021-11-27 15:22:14 +00:00
Rishabhraghwendra18
171b4b126d Doc: Update build doc (#5795) 2021-11-27 10:30:45 +00:00
Laurent Cozic
e4742f8b6a Update website 2021-11-26 18:15:05 +00:00
Laurent Cozic
a3703cc895 lock files 2021-11-26 18:13:23 +00:00
Laurent Cozic
ab6aeb7455 Doc: Fixed Android download links 2021-11-26 12:17:25 +00:00
Aryan
d96f8ee228 Doc: Updated dependencies for Linux (#5743) 2021-11-24 23:07:26 +00:00
Lee Matos
5981227c06 All: Improved S3 sync error handling and reliability, and upgraded S3 SDK (#5312) 2021-11-24 23:03:03 +00:00
Laurent Cozic
8e54a65ca5 Server: Increase default MAX_TIME_DRIFT to 100ms 2021-11-24 10:57:38 +00:00
Laurent Cozic
7985958f03 Server v2.6.12 2021-11-23 16:31:11 +00:00
Laurent Cozic
1e4cc16770 Exclude generated files 2021-11-23 16:29:22 +00:00
Laurent Cozic
75f729620e Server: Added task to compress changes older than 6 months 2021-11-23 16:25:36 +00:00
Laurent Cozic
799fe81449 Tools: Add missing package to Docker image 2021-11-23 16:09:09 +00:00
Laurent Cozic
080c3cc7dc Server: Allow specifying a max content size when importing content to new storage 2021-11-23 16:06:56 +00:00
Laurent Cozic
82defbdd7b Chore: Fixed mobile build 2021-11-23 12:12:27 +00:00
Laurent Cozic
c19e59f5da Cli: Ask for master password when encryption or decryption fails 2021-11-22 17:57:02 +00:00
Laurent Cozic
0e11273c45 Desktop: Fixes #5693: Opening a file with ctrl and click leads to an error in the Rich Text editor 2021-11-22 17:20:48 +00:00
79 changed files with 5406 additions and 1623 deletions

View File

@@ -78,6 +78,9 @@ packages/app-cli/app/command-e2ee.js.map
packages/app-cli/app/command-settingschema.d.ts
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-settingschema.js.map
packages/app-cli/app/command-sync.d.ts
packages/app-cli/app/command-sync.js
packages/app-cli/app/command-sync.js.map
packages/app-cli/app/command-testing.d.ts
packages/app-cli/app/command-testing.js
packages/app-cli/app/command-testing.js.map

View File

@@ -62,6 +62,15 @@ npm install
if [ "$IS_PULL_REQUEST" == "1" ] || [ "$IS_DEV_BRANCH" = "1" ]; then
echo "Step: Running tests..."
# On Linux, we run the Joplin Server tests using PostgreSQL
if [ "$IS_LINUX" == "1" ]; then
echo "Running Joplin Server tests using PostgreSQL..."
sudo docker-compose --file docker-compose.db-dev.yml up -d
export JOPLIN_TESTS_SERVER_DB=pg
else
echo "Running Joplin Server tests using SQLite..."
fi
# Need this because we're getting this error:
#
# @joplin/lib: FATAL ERROR: Ineffective mark-compacts near heap limit

View File

@@ -29,7 +29,8 @@ jobs:
brew install translate-toolkit
- name: Install Docker Engine
if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/server-v')
# if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/server-v')
if: runner.os == 'Linux'
run: |
sudo apt-get install -y apt-transport-https
sudo apt-get install -y ca-certificates

3
.gitignore vendored
View File

@@ -61,6 +61,9 @@ packages/app-cli/app/command-e2ee.js.map
packages/app-cli/app/command-settingschema.d.ts
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-settingschema.js.map
packages/app-cli/app/command-sync.d.ts
packages/app-cli/app/command-sync.js
packages/app-cli/app/command-sync.js.map
packages/app-cli/app/command-testing.d.ts
packages/app-cli/app/command-testing.js
packages/app-cli/app/command-testing.js.map

View File

@@ -21,7 +21,7 @@ There are also a few forks of existing packages under the "fork-*" name.
- Install node 14+ - https://nodejs.org/en/
- macOS: Install Cocoapods - `brew install cocoapods`
- Windows: Install Windows Build Tools - `npm install -g windows-build-tools --vs2015`
- Linux: Install dependencies - `sudo apt install libnss3 libsecret-1-dev python rsync`
- Linux: Install dependencies - `sudo apt install build-essential libnss3 libsecret-1-dev python rsync`
## Building
@@ -59,7 +59,6 @@ Normally the **bundler** should start automatically with the application. If it
## Building the clipper
cd packages/app-clipper/popup
npm install
npm run watch # To watch for changes
To test the extension please refer to the relevant pages for each browser: [Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Your_first_WebExtension#Trying_it_out) / [Chrome](https://developer.chrome.com/docs/extensions/mv3/getstarted/). Please note that the extension in dev mode will only connect to a dev instance of the desktop app (and vice-versa).

View File

@@ -40,6 +40,7 @@ RUN npm install --ignore-scripts
# prevent certain sub-packages, such as sqlite3, from being built
COPY --chown=$user:$user packages/fork-sax/package*.json ./packages/fork-sax/
COPY --chown=$user:$user packages/htmlpack/package*.json ./packages/htmlpack/
COPY --chown=$user:$user packages/renderer/package*.json ./packages/renderer/
COPY --chown=$user:$user packages/tools/package*.json ./packages/tools/
COPY --chown=$user:$user packages/lib/package*.json ./packages/lib/
@@ -66,6 +67,7 @@ RUN npm run bootstrapServerOnly
# Now copy the source files. Put lib and server last as they are more likely to change.
COPY --chown=$user:$user packages/fork-sax ./packages/fork-sax
COPY --chown=$user:$user packages/htmlpack ./packages/htmlpack
COPY --chown=$user:$user packages/renderer ./packages/renderer
COPY --chown=$user:$user packages/tools ./packages/tools
COPY --chown=$user:$user packages/lib ./packages/lib

View File

@@ -36,7 +36,7 @@ Linux | <a href='https://github.com/laurent22/joplin/releases/download/v2.5.12/J
Operating System | Download | Alt. Download
---|---|---
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeAndroid.png'/></a> | or download the APK file: [64-bit](https://github.com/laurent22/joplin-android/releases/download/android-v1.8.5/joplin-v1.8.5.apk) [32-bit](https://github.com/laurent22/joplin-android/releases/download/android-v1.8.5/joplin-v1.8.5-32bit.apk)
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeAndroid.png'/></a> | or download the APK file: [64-bit](https://github.com/laurent22/joplin-android/releases/download/android-v2.6.3/joplin-v2.6.3.apk) [32-bit](https://github.com/laurent22/joplin-android/releases/download/android-v2.6.3/joplin-v2.6.3-32bit.apk)
iOS | <a href='https://itunes.apple.com/us/app/joplin/id1315599797'><img alt='Get it on the App Store' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeIOS.png'/></a> | -
## Terminal application
@@ -64,7 +64,7 @@ The Web Clipper is a browser extension that allows you to save web pages and scr
# Sponsors
<!-- SPONSORS-ORG -->
<a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-github&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://tranio.com/italy/"><img title="Tranio" width="256" src="https://joplinapp.org/images/sponsors/Tranio.png"/></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://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-github&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://tranio.com/italy/"><img title="Tranio" width="256" src="https://joplinapp.org/images/sponsors/Tranio.png"/></a> <a href="https://usrigging.com/"><img title="U.S. Ringing Supply" width="256" src="https://joplinapp.org/images/sponsors/RingingSupply.svg"/></a>
<!-- SPONSORS-ORG -->
* * *
@@ -275,16 +275,20 @@ In the **desktop application** or **mobile application**, select "OneDrive" as t
In the **terminal application**, to initiate the synchronisation process, type `:sync`. You will be asked to follow a link to authorise the application (simply input your Microsoft credentials - you do not need to register with OneDrive).
## AWS S3 synchronisation
## S3 synchronisation
In the **desktop application** or **mobile application**, select "AWS S3 (Beta)" as the synchronisation target in the [Configuration screen](https://github.com/laurent22/joplin/blob/dev/readme/config_screen.md).
As of Joplin 2.x.x, Joplin supports multiple S3 providers. We expose some options that will need to be configured depending on your provider of choice. We have tested with UpCloud, AWS, and Linode. others should work as well.
- **AWS S3 Bucket:** The name of your Bucket, such as `joplin-bucket`
- **AWS S3 URL:** Fully qualified URL; By default this should be `https://s3.amazonaws.com/`
- **AWS key & AWS secret:** IAM user's programmatic access key. To create a new key & secret, visit [IAM Security Credentials](https://console.aws.amazon.com/iam/home#/security_credentials).
In the **desktop application** or **mobile application**, select "S3 (Beta)" as the synchronisation target in the [Configuration screen](https://github.com/laurent22/joplin/blob/dev/readme/config_screen.md).
- **S3 Bucket:** The name of your Bucket, such as `joplin-bucket`
- **S3 URL:** Fully qualified URL; For AWS this should be `https://s3.amazonaws.com/`
- **S3 Access Key & S3 Secret Key:** The User's programmatic access key. To create a new key & secret on AWS, visit [IAM Security Credentials](https://console.aws.amazon.com/iam/home#/security_credentials). For other providers follow their documentation.
- **S3 Region:** Some providers require you to provide the region of your bucket. This is usually in the form of "eu-west1" or something similar depending on your region. For providers that do not require a region, you can leave it blank.
- **Force Path Style**: This setting enables Joplin to talk to S3 providers using an older style S3 Path. Depending on your provider you may need to try with this on and off.
While creating a new Bucket for Joplin, disable **Bucket Versioning**, enable **Block all public access** and enable **Default encryption** with `Amazon S3 key (SSE-S3)`.
While creating a new Bucket for Joplin, disable **Bucket Versioning**, enable **Block all public access** and enable **Default encryption** with `Amazon S3 key (SSE-S3)`. Some providers do not expose these options, and it could create a syncing problem. Do attempt and report back so we can update the documentation appropriately.
To add a **Bucket Policy** from the AWS S3 Web Console, navigate to the **Permissions** tab. Temporarily disable **Block all public access** to edit the Bucket policy, something along the lines of:
```
@@ -311,7 +315,26 @@ To add a **Bucket Policy** from the AWS S3 Web Console, navigate to the **Permis
}
```
### Configuration settings for tested providers
All providers will require a bucket, Access Key, and Secret Key.
If you provide a configuration and you receive "success!" on the "check config" then your S3 sync should work for your provider. If you do not receive success, you may need to adjust your settings, or save them, restart the app, and attempt a sync. This may reveal more clear error messaging that will help you deduce the problem.
### AWS
- URL: https://s3.amazonaws.com
- Region: required
- Force Path Style: unchecked
### Linode
- URL: https://<region>.linodeobjects.com
- Region: empty
- Force Path Style: unchecked
### UpCloud
- URL: https://<account>.<region>.upcloudobjects.com (They will provide you with multiple URLs, the one that follows this pattern should work.)
- Region: required
- Force Path Style: unchecked
# Encryption

View File

@@ -294,6 +294,19 @@ https://github.com/laurent22/joplin/blob/dev/readme/changelog_android.md
<p><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&amp;business=E8JMYD2LQ8MMA&amp;lc=GB&amp;item_name=Joplin+Development&amp;currency_code=EUR&amp;bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted"><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-PayPal-green.svg" alt="Donate using PayPal"></a> <a href="https://github.com/sponsors/laurent22/"><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/GitHub-Badge.svg" alt="Sponsor on GitHub"></a> <a href="https://www.patreon.com/joplin"><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Patreon-Badge.svg" alt="Become a patron"></a> <a href="https://joplinapp.org/donate/#donations"><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-IBAN.svg" alt="Donate using IBAN"></a></p>
</div>
<h1>Joplin Android app changelog<a name="joplin-android-app-changelog" href="#joplin-android-app-changelog" class="heading-anchor">🔗</a></h1>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/android-v2.6.3">android-v2.6.3</a> (Pre-release) - 2021-11-21T16:59:46Z<a name="android-v2-6-3-https-github-com-laurent22-joplin-releases-tag-android-v2-6-3-pre-release-2021-11-21t16-59-46z" href="#android-v2-6-3-https-github-com-laurent22-joplin-releases-tag-android-v2-6-3-pre-release-2021-11-21t16-59-46z" class="heading-anchor">🔗</a></h2>
<ul>
<li>New: Add date format YYYY/MM/DD (#5759 by Helmut K. C. Tessarek)</li>
<li>New: Add support for faster built-in sync locks (#5662)</li>
<li>New: Add support for sharing notes when E2EE is enabled (#5529)</li>
<li>New: Added support for notebook icons (e97bb78)</li>
<li>Improved: Improved error message when synchronising with Joplin Server (#5754)</li>
<li>Improved: Makes it impossible to have multiple instances of the app open (#5587 by Filip Stanis)</li>
<li>Improved: Remove non-OSS dependencies (#5735 by <a href="https://github.com/muelli">@muelli</a>)</li>
<li>Fixed: Fixed issue that could cause application to needlessly lock the sync target (0de6e9e)</li>
<li>Fixed: Fixed issue with parts of HTML notes not being displayed in some cases (#5687)</li>
<li>Fixed: Sharing multiple notebooks via Joplin Server with the same user results in an error (#5721)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/android-v2.6.1">android-v2.6.1</a> (Pre-release) - 2021-11-02T20:49:53Z<a name="android-v2-6-1-https-github-com-laurent22-joplin-releases-tag-android-v2-6-1-pre-release-2021-11-02t20-49-53z" href="#android-v2-6-1-https-github-com-laurent22-joplin-releases-tag-android-v2-6-1-pre-release-2021-11-02t20-49-53z" class="heading-anchor">🔗</a></h2>
<ul>
<li>Improved: Upgraded React Native from 0.64 to 0.66 (66e79cc)</li>

View File

@@ -294,6 +294,16 @@ https://github.com/laurent22/joplin/blob/dev/readme/changelog_server.md
<p><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_donations&amp;business=E8JMYD2LQ8MMA&amp;lc=GB&amp;item_name=Joplin+Development&amp;currency_code=EUR&amp;bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHosted"><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-PayPal-green.svg" alt="Donate using PayPal"></a> <a href="https://github.com/sponsors/laurent22/"><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/GitHub-Badge.svg" alt="Sponsor on GitHub"></a> <a href="https://www.patreon.com/joplin"><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Patreon-Badge.svg" alt="Become a patron"></a> <a href="https://joplinapp.org/donate/#donations"><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-IBAN.svg" alt="Donate using IBAN"></a></p>
</div>
<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.6.12">server-v2.6.12</a> - 2021-11-23T16:30:27Z<a name="server-v2-6-12-https-github-com-laurent22-joplin-releases-tag-server-v2-6-12-2021-11-23t16-30-27z" href="#server-v2-6-12-https-github-com-laurent22-joplin-releases-tag-server-v2-6-12-2021-11-23t16-30-27z" class="heading-anchor">🔗</a></h2>
<ul>
<li>New: Added task to compress changes older than 6 months (75f7296)</li>
<li>Improved: Allow specifying a max content size when importing content to new storage (080c3cc)</li>
<li>Improved: Check for time drift when the server starts (#5738)</li>
<li>Improved: Display more debug info in error log (3716972)</li>
<li>Improved: Display more detailed error messages on SQL query errors (42a4edb)</li>
<li>Improved: Perform storage checks before starting services (16d5047)</li>
<li>Fixed: Fixed HandleOversizedAccounts task interval (fc419d9)</li>
</ul>
<h2><a href="https://github.com/laurent22/joplin/releases/tag/server-v2.6.11">server-v2.6.11</a> - 2021-11-14T17:14:51Z<a name="server-v2-6-11-https-github-com-laurent22-joplin-releases-tag-server-v2-6-11-2021-11-14t17-14-51z" href="#server-v2-6-11-https-github-com-laurent22-joplin-releases-tag-server-v2-6-11-2021-11-14t17-14-51z" class="heading-anchor">🔗</a></h2>
<ul>
<li>Improved: Prevent large data blobs from crashing the application (5eb3a92)</li>

View File

@@ -340,7 +340,7 @@ https://github.com/laurent22/joplin/blob/dev/README.md
<tr>
<td>Android</td>
<td><a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='/images/BadgeAndroid.png'/></a></td>
<td>or download the APK file: <a href="https://github.com/laurent22/joplin-android/releases/download/android-v1.8.5/joplin-v1.8.5.apk">64-bit</a> <a href="https://github.com/laurent22/joplin-android/releases/download/android-v1.8.5/joplin-v1.8.5-32bit.apk">32-bit</a></td>
<td>or download the APK file: <a href="https://github.com/laurent22/joplin-android/releases/download/android-v2.6.3/joplin-v2.6.3.apk">64-bit</a> <a href="https://github.com/laurent22/joplin-android/releases/download/android-v2.6.3/joplin-v2.6.3-32bit.apk">32-bit</a></td>
</tr>
<tr>
<td>iOS</td>
@@ -386,7 +386,7 @@ https://github.com/laurent22/joplin/blob/dev/README.md
<p>The Web Clipper is a browser extension that allows you to save web pages and screenshots from your browser. For more information on how to install and use it, see the <a href="/clipper/">Web Clipper Help Page</a>.</p>
<h1>Sponsors<a name="sponsors" href="#sponsors" class="heading-anchor">🔗</a></h1>
<!-- SPONSORS-ORG -->
<p><a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-github&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://tranio.com/italy/"><img title="Tranio" width="256" src="https://joplinapp.org/images/sponsors/Tranio.png"/></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></p>
<p><a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-github&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://tranio.com/italy/"><img title="Tranio" width="256" src="https://joplinapp.org/images/sponsors/Tranio.png"/></a> <a href="https://usrigging.com/"><img title="U.S. Ringing Supply" width="256" src="https://joplinapp.org/images/sponsors/RingingSupply.svg"/></a></p>
<!-- SPONSORS-ORG -->
<hr>
<!-- SPONSORS-GITHUB -->
@@ -539,14 +539,17 @@ Joplin is also capable of exporting to a number of other formats including HTML
<p>When syncing with OneDrive, Joplin creates a sub-directory in OneDrive, in /Apps/Joplin and read/write the notes and notebooks from it. The application does not have access to anything outside this directory.</p>
<p>In the <strong>desktop application</strong> or <strong>mobile application</strong>, select &quot;OneDrive&quot; as the synchronisation target in the <a href="/config_screen/">Configuration screen</a>. Then, to initiate the synchronisation process, click on the &quot;Synchronise&quot; button in the sidebar and follow the instructions.</p>
<p>In the <strong>terminal application</strong>, to initiate the synchronisation process, type <code>:sync</code>. You will be asked to follow a link to authorise the application (simply input your Microsoft credentials - you do not need to register with OneDrive).</p>
<h2>AWS S3 synchronisation<a name="aws-s3-synchronisation" href="#aws-s3-synchronisation" class="heading-anchor">🔗</a></h2>
<p>In the <strong>desktop application</strong> or <strong>mobile application</strong>, select &quot;AWS S3 (Beta)&quot; as the synchronisation target in the <a href="/config_screen/">Configuration screen</a>.</p>
<h2>S3 synchronisation<a name="s3-synchronisation" href="#s3-synchronisation" class="heading-anchor">🔗</a></h2>
<p>As of Joplin 2.x.x, Joplin supports multiple S3 providers. We expose some options that will need to be configured depending on your provider of choice. We have tested with UpCloud, AWS, and Linode. others should work as well.</p>
<p>In the <strong>desktop application</strong> or <strong>mobile application</strong>, select &quot;S3 (Beta)&quot; as the synchronisation target in the <a href="/config_screen/">Configuration screen</a>.</p>
<ul>
<li><strong>AWS S3 Bucket:</strong> The name of your Bucket, such as <code>joplin-bucket</code></li>
<li><strong>AWS S3 URL:</strong> Fully qualified URL; By default this should be <code>https://s3.amazonaws.com/</code></li>
<li><strong>AWS key &amp; AWS secret:</strong> IAM user's programmatic access key. To create a new key &amp; secret, visit <a href="https://console.aws.amazon.com/iam/home#/security_credentials">IAM Security Credentials</a>.</li>
<li><strong>S3 Bucket:</strong> The name of your Bucket, such as <code>joplin-bucket</code></li>
<li><strong>S3 URL:</strong> Fully qualified URL; For AWS this should be <code>https://s3.amazonaws.com/</code></li>
<li><strong>S3 Access Key &amp; S3 Secret Key:</strong> The User's programmatic access key. To create a new key &amp; secret on AWS, visit <a href="https://console.aws.amazon.com/iam/home#/security_credentials">IAM Security Credentials</a>. For other providers follow their documentation.</li>
<li><strong>S3 Region:</strong> Some providers require you to provide the region of your bucket. This is usually in the form of &quot;eu-west1&quot; or something similar depending on your region. For providers that do not require a region, you can leave it blank.</li>
<li><strong>Force Path Style</strong>: This setting enables Joplin to talk to S3 providers using an older style S3 Path. Depending on your provider you may need to try with this on and off.</li>
</ul>
<p>While creating a new Bucket for Joplin, disable <strong>Bucket Versioning</strong>, enable <strong>Block all public access</strong> and enable <strong>Default encryption</strong> with <code>Amazon S3 key (SSE-S3)</code>.</p>
<p>While creating a new Bucket for Joplin, disable <strong>Bucket Versioning</strong>, enable <strong>Block all public access</strong> and enable <strong>Default encryption</strong> with <code>Amazon S3 key (SSE-S3)</code>. Some providers do not expose these options, and it could create a syncing problem. Do attempt and report back so we can update the documentation appropriately.</p>
<p>To add a <strong>Bucket Policy</strong> from the AWS S3 Web Console, navigate to the <strong>Permissions</strong> tab. Temporarily disable <strong>Block all public access</strong> to edit the Bucket policy, something along the lines of:</p>
<pre><code>{
&quot;Version&quot;: &quot;2012-10-17&quot;,
@@ -570,6 +573,27 @@ Joplin is also capable of exporting to a number of other formats including HTML
]
}
</code></pre>
<h3>Configuration settings for tested providers<a name="configuration-settings-for-tested-providers" href="#configuration-settings-for-tested-providers" class="heading-anchor">🔗</a></h3>
<p>All providers will require a bucket, Access Key, and Secret Key.</p>
<p>If you provide a configuration and you receive &quot;success!&quot; on the &quot;check config&quot; then your S3 sync should work for your provider. If you do not receive success, you may need to adjust your settings, or save them, restart the app, and attempt a sync. This may reveal more clear error messaging that will help you deduce the problem.</p>
<h3>AWS<a name="aws" href="#aws" class="heading-anchor">🔗</a></h3>
<ul>
<li>URL: <a href="https://s3.amazonaws.com">https://s3.amazonaws.com</a></li>
<li>Region: required</li>
<li>Force Path Style: unchecked</li>
</ul>
<h3>Linode<a name="linode" href="#linode" class="heading-anchor">🔗</a></h3>
<ul>
<li>URL: https://<region>.linodeobjects.com</li>
<li>Region: empty</li>
<li>Force Path Style: unchecked</li>
</ul>
<h3>UpCloud<a name="upcloud" href="#upcloud" class="heading-anchor">🔗</a></h3>
<ul>
<li>URL: https://<account>.<region>.upcloudobjects.com (They will provide you with multiple URLs, the one that follows this pattern should work.)</li>
<li>Region: required</li>
<li>Force Path Style: unchecked</li>
</ul>
<h1>Encryption<a name="encryption" href="#encryption" class="heading-anchor">🔗</a></h1>
<p>Joplin supports end-to-end encryption (E2EE) on all the applications. E2EE is a system where only the owner of the notes, notebooks, tags or resources can read them. It prevents potential eavesdroppers - including telecom providers, internet providers, and even the developers of Joplin from being able to access the data. Please see the <a href="/e2ee/">End-To-End Encryption Tutorial</a> for more information about this feature and how to enable it.</p>
<p>For a more technical description, mostly relevant for development or to review the method being used, please see the <a href="/spec/e2ee/">Encryption specification</a>.</p>

View File

@@ -571,10 +571,10 @@
<br />
<div class="text-center sponsors-org">
<a class="sponsor-org-item" href="https:&#x2F;&#x2F;usrigging.com&#x2F;"><img title="U.S. Ringing Supply" src="&#x2F;images/sponsors/RingingSupply.svg"></a>
<a class="sponsor-org-item" href="https:&#x2F;&#x2F;seirei.ne.jp"><img title="Serei Network" src="&#x2F;images/sponsors/SeireiNetwork.png"></a>
<a class="sponsor-org-item" href="https:&#x2F;&#x2F;tranio.com&#x2F;italy&#x2F;"><img title="Tranio" src="&#x2F;images/sponsors/Tranio.png"></a>
<a class="sponsor-org-item" href="https:&#x2F;&#x2F;www.hosting.de&#x2F;nextcloud&#x2F;?mtm_campaign&#x3D;managed-nextcloud&amp;mtm_kwd&#x3D;joplinapp&amp;mtm_source&#x3D;joplinapp-webseite&amp;mtm_medium&#x3D;banner"><img title="Hosting.de" src="&#x2F;images/sponsors/HostingDe.png"></a>
<a class="sponsor-org-item" href="https:&#x2F;&#x2F;tranio.com&#x2F;italy&#x2F;"><img title="Tranio" src="&#x2F;images/sponsors/Tranio.png"></a>
<a class="sponsor-org-item" href="https:&#x2F;&#x2F;seirei.ne.jp"><img title="Serei Network" src="&#x2F;images/sponsors/SeireiNetwork.png"></a>
<a class="sponsor-org-item" href="https:&#x2F;&#x2F;usrigging.com&#x2F;"><img title="U.S. Ringing Supply" src="&#x2F;images/sponsors/RingingSupply.svg"></a>
</div>
<div class="text-center sponsors-github">

View File

@@ -80,24 +80,34 @@
"sync.8.path": {
"type": "string",
"default": "",
"description": "AWS S3 bucket. 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": "S3 bucket. 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.8.url": {
"type": "string",
"default": "https://s3.amazonaws.com/",
"description": "AWS S3 URL"
"description": "S3 URL"
},
"sync.8.region": {
"type": "string",
"default": "",
"description": "Region"
},
"sync.8.username": {
"type": "string",
"default": "",
"description": "AWS key"
"description": "Access Key"
},
"sync.8.password": {
"type": "string",
"default": "",
"description": "AWS secret",
"description": "Secret Key",
"$comment": "private"
},
"sync.8.forcePathStyle": {
"type": "boolean",
"default": false,
"description": "Force Path Style"
},
"sync.9.path": {
"type": "string",
"default": "",
@@ -312,7 +322,8 @@
"YYYY-MM-DD",
"DD.MM.YYYY",
"YYYY.MM.DD",
"YYMMDD"
"YYMMDD",
"YYYY/MM/DD"
]
},
"timeFormat": {

File diff suppressed because it is too large Load Diff

View File

@@ -223,6 +223,7 @@
"packages/app-cli/tests/support/plugins/toc/**/dist/": true,
"packages/app-cli/tests/sync/": true,
"packages/app-cli/tests/tmp/": true,
"packages/htmlpack/dist/": true,
"packages/app-clipper/**/dist/": true,
"packages/app-clipper/content_scripts/**/*.bundle.js": true,
"packages/app-clipper/dist/": true,

View File

@@ -23,9 +23,9 @@
"dependencyTree": "madge",
"generateDatabaseTypes": "node packages/tools/generate-database-types",
"linkChecker": "linkchecker https://joplinapp.org",
"linter-ci": "./node_modules/.bin/eslint --resolve-plugins-relative-to . --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
"linter-precommit": "./node_modules/.bin/eslint --resolve-plugins-relative-to . --fix --ext .js --ext .jsx --ext .ts --ext .tsx",
"linter": "./node_modules/.bin/eslint --resolve-plugins-relative-to . --fix --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
"linter-ci": "eslint --resolve-plugins-relative-to . --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
"linter-precommit": "eslint --resolve-plugins-relative-to . --fix --ext .js --ext .jsx --ext .ts --ext .tsx",
"linter": "eslint --resolve-plugins-relative-to . --fix --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
"postinstall": "npm run bootstrap --no-ci && npm run build",
"publishAll": "git pull && npm run build && lerna version --yes --no-private --no-git-tag-version && gulp completePublishAll",
"releaseAndroid": "npm run build && export PATH=\"/usr/local/opt/openjdk@11/bin:$PATH\" && node packages/tools/release-android.js",

View File

@@ -6,8 +6,8 @@ import BaseItem from '@joplin/lib/models/BaseItem';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import * as pathUtils from '@joplin/lib/path-utils';
import { getEncryptionEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { generateMasterKeyAndEnableEncryption, loadMasterKeysFromSettings, setupAndDisableEncryption } from '@joplin/lib/services/e2ee/utils';
import { getEncryptionEnabled, localSyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { generateMasterKeyAndEnableEncryption, loadMasterKeysFromSettings, masterPasswordIsValid, setupAndDisableEncryption } from '@joplin/lib/services/e2ee/utils';
const imageType = require('image-type');
const readChunk = require('read-chunk');
@@ -40,6 +40,13 @@ class Command extends BaseCommand {
this.stdout(_('Operation cancelled'));
return false;
}
const masterKey = localSyncInfo().masterKeys.find(mk => mk.id === masterKeyId);
if (!(await masterPasswordIsValid(password, masterKey))) {
this.stdout(_('Invalid password'));
return false;
}
Setting.setObjectValue('encryption.passwordCache', masterKeyId, password);
await loadMasterKeysFromSettings(EncryptionService.instance());
return true;

View File

@@ -1,25 +1,24 @@
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import MigrationHandler from '@joplin/lib/services/synchronizer/MigrationHandler';
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import Synchronizer from '@joplin/lib/Synchronizer';
import { masterKeysWithoutPassword } from '@joplin/lib/services/e2ee/utils';
const { BaseCommand } = require('./base-command.js');
const { app } = require('./app.js');
const { _ } = require('@joplin/lib/locale');
const { OneDriveApiNodeUtils } = require('@joplin/lib/onedrive-api-node-utils.js');
const Setting = require('@joplin/lib/models/Setting').default;
const ResourceFetcher = require('@joplin/lib/services/ResourceFetcher').default;
const Synchronizer = require('@joplin/lib/Synchronizer').default;
const { reg } = require('@joplin/lib/registry.js');
const { cliUtils } = require('./cli-utils.js');
const md5 = require('md5');
const locker = require('proper-lockfile');
const fs = require('fs-extra');
const SyncTargetRegistry = require('@joplin/lib/SyncTargetRegistry').default;
const MigrationHandler = require('@joplin/lib/services/synchronizer/MigrationHandler').default;
class Command extends BaseCommand {
constructor() {
super();
this.syncTargetId_ = null;
this.releaseLockFn_ = null;
this.oneDriveApiUtils_ = null;
}
private syncTargetId_: number = null;
private releaseLockFn_: Function = null;
private oneDriveApiUtils_: any = null;
usage() {
return 'sync';
@@ -37,9 +36,9 @@ class Command extends BaseCommand {
];
}
static lockFile(filePath) {
static lockFile(filePath: string): Promise<Function> {
return new Promise((resolve, reject) => {
locker.lock(filePath, { stale: 1000 * 60 * 5 }, (error, release) => {
locker.lock(filePath, { stale: 1000 * 60 * 5 }, (error: any, release: any) => {
if (error) {
reject(error);
return;
@@ -50,9 +49,9 @@ class Command extends BaseCommand {
});
}
static isLocked(filePath) {
static isLocked(filePath: string) {
return new Promise((resolve, reject) => {
locker.check(filePath, (error, isLocked) => {
locker.check(filePath, (error: any, isLocked: boolean) => {
if (error) {
reject(error);
return;
@@ -71,7 +70,7 @@ class Command extends BaseCommand {
// OneDrive
this.oneDriveApiUtils_ = new OneDriveApiNodeUtils(syncTarget.api());
const auth = await this.oneDriveApiUtils_.oauthDance({
log: (...s) => {
log: (...s: any[]) => {
return this.stdout(...s);
},
});
@@ -118,7 +117,7 @@ class Command extends BaseCommand {
return !!this.oneDriveApiUtils_;
}
async action(args) {
async action(args: any) {
this.releaseLockFn_ = null;
// Lock is unique per profile/database
@@ -166,12 +165,12 @@ class Command extends BaseCommand {
const sync = await syncTarget.synchronizer();
const options = {
onProgress: report => {
const options: any = {
onProgress: (report: any) => {
const lines = Synchronizer.reportToLines(report);
if (lines.length) cliUtils.redraw(lines.join(' '));
},
onMessage: msg => {
onMessage: (msg: string) => {
cliUtils.redrawDone();
this.stdout(msg);
},
@@ -238,6 +237,9 @@ class Command extends BaseCommand {
await ResourceFetcher.instance().waitForAllFinished();
}
const noPasswordMkIds = await masterKeysWithoutPassword();
if (noPasswordMkIds.length) this.stdout(`/!\\ ${_('Your password is needed to decrypt some of your data. Type `:e2ee decrypt` to set it.')}`);
await app().refreshCurrentFolder();
} catch (error) {
cleanUp();

View File

@@ -134,6 +134,7 @@ body {
.App .Folders select {
flex: 1;
width: 100%;
}
.App .Tags input {

View File

@@ -420,8 +420,8 @@ class AppComponent extends Component {
<div className="Controls">
<ul>
<li><a className="Button" href="#" onClick={this.clipSimplified_click} title={simplifiedPageButtonTooltip}>{simplifiedPageButtonLabel}</a></li>
<li><a className="Button" href="#" onClick={this.clipComplete_click}>Clip complete page</a></li>
<li><a className="Button" href="#" onClick={this.clipCompleteHtml_click}>Clip complete page (HTML) (Beta)</a></li>
<li><a className="Button" href="#" onClick={this.clipComplete_click}>Clip complete page (Markdown)</a></li>
<li><a className="Button" href="#" onClick={this.clipCompleteHtml_click}>Clip complete page (HTML)</a></li>
<li><a className="Button" href="#" onClick={this.clipSelection_click}>Clip selection</a></li>
<li><a className="Button" href="#" onClick={this.clipScreenshot_click}>Clip screenshot</a></li>
<li><a className="Button" href="#" onClick={this.clipUrl_click}>Clip URL</a></li>

View File

@@ -7,12 +7,12 @@ const bridge = require('@electron/remote').require('./bridge').default;
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import htmlUtils from '@joplin/lib/htmlUtils';
import Logger from '@joplin/lib/Logger';
const { fileUriToPath } = require('@joplin/lib/urlUtils');
const joplinRendererUtils = require('@joplin/renderer').utils;
const { clipboard } = require('electron');
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
const md5 = require('md5');
const path = require('path');
const uri2path = require('file-uri-to-path');
const logger = Logger.create('resourceHandling');
@@ -150,7 +150,7 @@ export async function processPastedHtml(html: string) {
if (!mappedResources[imageSrc]) {
try {
if (imageSrc.startsWith('file')) {
const imageFilePath = path.normalize(uri2path(imageSrc));
const imageFilePath = path.normalize(fileUriToPath(imageSrc));
const resourceDirPath = path.normalize(Setting.value('resourceDir'));
if (imageFilePath.startsWith(resourceDirPath)) {

View File

@@ -4,12 +4,13 @@ import contextMenu, { openItemById } from './contextMenu';
import { _ } from '@joplin/lib/locale';
import CommandService from '@joplin/lib/services/CommandService';
import PostMessageService from '@joplin/lib/services/PostMessageService';
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import { reg } from '@joplin/lib/registry';
import shim from '@joplin/lib/shim';
const bridge = require('@electron/remote').require('./bridge').default;
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');
const { fileUriToPath } = require('@joplin/lib/urlUtils');
export default function useMessageHandler(scrollWhenReady: any, setScrollWhenReady: Function, editorRef: any, setLocalSearchResultCount: Function, dispatch: Function, formNote: FormNote) {
return useCallback(async (event: any) => {
@@ -17,7 +18,7 @@ export default function useMessageHandler(scrollWhenReady: any, setScrollWhenRea
const args = event.args;
const arg0 = args && args.length >= 1 ? args[0] : null;
// if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, arg0);
if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, arg0);
if (msg.indexOf('error:') === 0) {
const s = msg.split(':');
@@ -58,7 +59,7 @@ export default function useMessageHandler(scrollWhenReady: any, setScrollWhenRea
// 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));
const decodedPath = fileUriToPath(urlDecode(msg), shim.platformName());
require('electron').shell.openPath(decodedPath);
} else {
require('electron').shell.openExternal(msg);

View File

@@ -19,7 +19,6 @@
"countable": "^3.0.1",
"debounce": "^1.2.0",
"electron-window-state": "^4.1.1",
"file-uri-to-path": "^2.0.0",
"formatcoords": "^1.1.3",
"fs-extra": "^5.0.0",
"highlight.js": "^10.2.1",
@@ -7858,13 +7857,6 @@
"pend": "~1.2.0"
}
},
"node_modules/file-uri-to-path": {
"version": "2.0.0",
"integrity": "sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg==",
"engines": {
"node": ">= 6"
}
},
"node_modules/filelist": {
"version": "1.0.2",
"integrity": "sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==",
@@ -16233,6 +16225,7 @@
},
"node_modules/sqlite3": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.0.2.tgz",
"integrity": "sha512-1SdTNo+BVU211Xj1csWa8lV6KM0CtucDwRyA0VHl91wEH1Mgh7RxUpI4rVvG7OhHrzCSGaVyW5g8vKvlrk9DJA==",
"hasInstallScript": true,
"dependencies": {
@@ -24127,10 +24120,6 @@
"pend": "~1.2.0"
}
},
"file-uri-to-path": {
"version": "2.0.0",
"integrity": "sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg=="
},
"filelist": {
"version": "1.0.2",
"integrity": "sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==",
@@ -30514,6 +30503,7 @@
},
"sqlite3": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.0.2.tgz",
"integrity": "sha512-1SdTNo+BVU211Xj1csWa8lV6KM0CtucDwRyA0VHl91wEH1Mgh7RxUpI4rVvG7OhHrzCSGaVyW5g8vKvlrk9DJA==",
"requires": {
"node-addon-api": "^3.0.0",

View File

@@ -146,7 +146,6 @@
"countable": "^3.0.1",
"debounce": "^1.2.0",
"electron-window-state": "^4.1.1",
"file-uri-to-path": "^2.0.0",
"formatcoords": "^1.1.3",
"fs-extra": "^5.0.0",
"highlight.js": "^10.2.1",

View File

@@ -13,6 +13,15 @@
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<!-- Need this block to support viewing files in with Android API 30 -->
<!-- https://github.com/vinzscam/react-native-file-viewer/issues/94#issuecomment-767743365 -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="*/*" />
</intent>
</queries>
<!--
android:requestLegacyExternalStorage: Android 10 introduced new "scoped storage" mechanism.
Apps targeting Android 10 (sdk 29) can no longer freely access arbitrary paths on the shared storage.

View File

@@ -1,17 +1,56 @@
import * as React from 'react';
import { themeStyle } from '@joplin/lib/theme';
import { _ } from '@joplin/lib/locale';
const { View, Button, Text } = require('react-native');
const PopupDialog = require('react-native-popup-dialog').default;
const { DialogTitle, DialogButton } = require('react-native-popup-dialog');
const { Modal, View, Button, Text, StyleSheet } = require('react-native');
import time from '@joplin/lib/time';
const DateTimePickerModal = require('react-native-modal-datetime-picker').default;
export default class SelectDateTimeDialog extends React.PureComponent<any, any> {
const styles = StyleSheet.create({
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
marginTop: 22,
},
modalView: {
display: 'flex',
flexDirection: 'column',
margin: 10,
backgroundColor: 'white',
borderRadius: 10,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
button: {
borderRadius: 20,
padding: 10,
elevation: 2,
},
buttonOpen: {
backgroundColor: '#F194FF',
},
buttonClose: {
backgroundColor: '#2196F3',
},
textStyle: {
color: 'white',
fontWeight: 'bold',
textAlign: 'center',
},
modalText: {
marginBottom: 15,
textAlign: 'center',
},
});
private dialog_: any = null;
private shown_: boolean = false;
export default class SelectDateTimeDialog extends React.PureComponent<any, any> {
constructor(props: any) {
super(props);
@@ -32,24 +71,6 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any>
if (newProps.date != this.state.date) {
this.setState({ date: newProps.date });
}
if ('shown' in newProps && newProps.shown != this.shown_) {
this.show(newProps.shown);
}
}
show(doShow: boolean = true) {
if (doShow) {
this.dialog_.show();
} else {
this.dialog_.dismiss();
}
this.shown_ = doShow;
}
dismiss() {
this.show(false);
}
onAccept() {
@@ -77,12 +98,10 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any>
}
renderContent() {
if (!this.shown_) return <View/>;
const theme = themeStyle(this.props.themeId);
return (
<View style={{ flex: 1, margin: 20, alignItems: 'center' }}>
<View style={{ flex: 0, margin: 20, alignItems: 'center' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{ this.state.date && <Text style={{ ...theme.normalText, marginRight: 10 }}>{time.formatDateToLocal(this.state.date)}</Text> }
<Button title="Set date" onPress={this.onSetDate} />
@@ -100,25 +119,43 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any>
}
render() {
const clearAlarmText = _('Clear alarm'); // For unknown reasons, this particular string doesn't get translated if it's directly in the text property below
const modalVisible = this.props.shown;
const popupActions = [
<DialogButton text={_('Save alarm')} align="center" onPress={() => this.onAccept()} key="saveButton" />,
<DialogButton text={clearAlarmText} align="center" onPress={() => this.onClear()} key="clearButton" />,
<DialogButton text={_('Cancel')} align="center" onPress={() => this.onReject()} key="cancelButton" />,
];
if (!modalVisible) return null;
const theme = themeStyle(this.props.themeId);
return (
<PopupDialog
ref={(dialog: any) => { this.dialog_ = dialog; }}
dialogTitle={<DialogTitle title={_('Set alarm')} />}
actions={popupActions}
dismissOnTouchOutside={false}
width={0.9}
height={350}
>
{this.renderContent()}
</PopupDialog>
<View style={styles.centeredView}>
<Modal
transparent={true}
visible={modalVisible}
onRequestClose={() => {
this.onReject();
}}
>
<View style={styles.centeredView}>
<View style={{ ...styles.modalView, backgroundColor: theme.backgroundColor }}>
<View style={{ padding: 15, paddingBottom: 0, flex: 0, width: '100%', borderBottomWidth: 1, borderBottomColor: theme.dividerColor, borderBottomStyle: 'solid' }}>
<Text style={{ ...styles.modalText, color: theme.color, fontSize: 14, fontWeight: 'bold' }}>{_('Set alarm')}</Text>
</View>
{this.renderContent()}
<View style={{ padding: 20, borderTopWidth: 1, borderTopStyle: 'solid', borderTopColor: theme.dividerColor }}>
<View style={{ marginBottom: 10 }}>
<Button title={_('Save alarm')} onPress={() => this.onAccept()} key="saveButton" />
</View>
<View style={{ marginBottom: 10 }}>
<Button title={_('Clear alarm')} onPress={() => this.onClear()} key="clearButton" />
</View>
<View style={{ marginBottom: 10 }}>
<Button title={_('Cancel')} onPress={() => this.onReject()} key="cancelButton" />
</View>
</View>
</View>
</View>
</Modal>
</View>
);
}

View File

@@ -44,10 +44,13 @@ import SelectDateTimeDialog from '../SelectDateTimeDialog';
import ShareExtension from '../../utils/ShareExtension.js';
import CameraView from '../CameraView';
import { NoteEntity } from '@joplin/lib/services/database/types';
import Logger from '@joplin/lib/Logger';
const urlUtils = require('@joplin/lib/urlUtils');
const emptyArray: any[] = [];
const logger = Logger.create('screens/Note');
class NoteScreenComponent extends BaseScreenComponent {
static navigationOptions(): any {
return { header: null };
@@ -186,6 +189,8 @@ class NoteScreenComponent extends BaseScreenComponent {
} else if (item.type_ === BaseModel.TYPE_RESOURCE) {
if (!(await Resource.isReady(item))) throw new Error(_('This attachment is not downloaded or not decrypted yet.'));
const resourcePath = Resource.fullPath(item);
logger.info(`Opening resource: ${resourcePath}`);
await FileViewer.open(resourcePath);
} else {
throw new Error(_('The Joplin mobile app does not currently support this type of link: %s', BaseModel.modelTypeToName(item.type_)));

View File

@@ -6,6 +6,10 @@
// So there's basically still a one way flux: React => SQLite => Redux => React
// For aws-sdk-js-v3
import 'react-native-get-random-values';
import 'react-native-url-polyfill/auto';
import { LogBox, AppRegistry } from 'react-native';
const Root = require('./root').default;

View File

@@ -227,6 +227,8 @@ PODS:
- React-Core
- react-native-geolocation (2.0.2):
- React
- react-native-get-random-values (1.7.1):
- React-Core
- react-native-image-picker (2.3.4):
- React-Core
- react-native-image-resizer (1.3.0):
@@ -356,6 +358,7 @@ DEPENDENCIES:
- react-native-camera (from `../node_modules/react-native-camera`)
- react-native-document-picker (from `../node_modules/react-native-document-picker`)
- "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)"
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
- react-native-image-resizer (from `../node_modules/react-native-image-resizer`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
@@ -439,6 +442,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-document-picker"
react-native-geolocation:
:path: "../node_modules/@react-native-community/geolocation"
react-native-get-random-values:
:path: "../node_modules/react-native-get-random-values"
react-native-image-picker:
:path: "../node_modules/react-native-image-picker"
react-native-image-resizer:
@@ -527,6 +532,7 @@ SPEC CHECKSUMS:
react-native-camera: 35854c4f764a4a6cf61c1c3525888b92f0fe4b31
react-native-document-picker: 0bba80cc56caab1f67dbaa81ff557e3a9b7f2b9f
react-native-geolocation: c956aeb136625c23e0dce0467664af2c437888c9
react-native-get-random-values: 2c4ff6b44cb71291dabe9a8ae87d3877dcf387da
react-native-image-picker: c6d75c4ab2cf46f9289f341242b219cb3c1180d3
react-native-image-resizer: a79bcffdef1b52160ff91db0d6fa24816a4ff332
react-native-netinfo: e849fc21ca2f4128a5726c801a82fc6f4a6db50d

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@
"constants-browserify": "^1.0.0",
"crypto-browserify": "^3.12.0",
"events": "^3.2.0",
"joplin-rn-alarm-notification": "^1.0.3",
"joplin-rn-alarm-notification": "^1.0.5",
"jsc-android": "241213.1.0",
"md5": "^2.2.1",
"prop-types": "^15.6.0",
@@ -42,10 +42,10 @@
"react-native-dropdownalert": "^3.1.2",
"react-native-file-viewer": "^2.1.4",
"react-native-fs": "^2.16.6",
"react-native-get-random-values": "^1.7.0",
"react-native-image-picker": "^2.3.4",
"react-native-image-resizer": "^1.3.0",
"react-native-modal-datetime-picker": "^9.0.0",
"react-native-popup-dialog": "^0.9.41",
"react-native-popup-menu": "^0.10.0",
"react-native-quick-actions": "^0.3.13",
"react-native-rsa-native": "^2.0.4",
@@ -53,6 +53,7 @@
"react-native-share": "^7.2.1",
"react-native-side-menu": "^1.1.3",
"react-native-sqlite-storage": "^5.0.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-vector-icons": "^7.1.0",
"react-native-version-info": "^1.1.0",
"react-native-webview": "^10.9.2",
@@ -63,6 +64,7 @@
"stream-browserify": "^3.0.0",
"string-natural-compare": "^2.0.2",
"timers": "^0.1.1",
"url": "^0.11.0",
"valid-url": "^1.0.9"
},
"devDependencies": {

View File

@@ -476,7 +476,7 @@ async function initialize(dispatch: Function) {
if (Setting.value('env') == 'prod') {
await db.open({ name: 'joplin.sqlite' });
} else {
await db.open({ name: 'joplin-107.sqlite' });
await db.open({ name: 'joplin-1.sqlite' });
// await db.clearForTesting();
}
@@ -729,6 +729,11 @@ class AppComponent extends React.Component {
});
try {
NetInfo.configure({
reachabilityUrl: 'https://joplinapp.org/connection_check/',
reachabilityTest: async (response) => response.status === 200,
});
// This will be called right after adding the event listener
// so there's no need to check netinfo on startup
this.unsubscribeNetInfoHandler_ = NetInfo.addEventListener(({ type, details }) => {

View File

@@ -56,7 +56,7 @@ export default class AlarmServiceDriver {
small_icon: 'ic_launcher_foreground', // Android requires the icon to be transparent
color: 'blue',
data: {
joplinNotificationId: `${notification.id}`,
joplinNotificationId: notification.id,
noteId: notification.noteId,
},
};

View File

@@ -1,8 +1,6 @@
import FsDriverBase from '@joplin/lib/fs-driver-base';
const RNFetchBlob = require('rn-fetch-blob').default;
const RNFS = require('react-native-fs');
const { Writable } = require('stream-browserify');
const { Buffer } = require('buffer');
export default class FsDriverRN extends FsDriverBase {
public appendFileSync() {
@@ -26,27 +24,6 @@ export default class FsDriverRN extends FsDriverBase {
return await this.unlink(path);
}
public writeBinaryFile(path: string, content: any) {
const buffer = Buffer.from(content);
return RNFetchBlob.fs.writeStream(path, 'base64').then((stream: any) => {
const fileStream = new Writable({
write(chunk: any, _encoding: any, callback: Function) {
this.stream.write(chunk.toString('base64'));
callback();
},
final(callback: Function) {
this.stream.close();
callback();
},
});
// using options.construct is not implemented in readable-stream so lets
// pass the stream from RNFetchBlob to the Writable instance here
fileStream.stream = stream;
fileStream.write(buffer);
fileStream.end();
});
}
// Returns a format compatible with Node.js format
private rnfsStatToStd_(stat: any, path: string) {
return {

View File

@@ -11,7 +11,7 @@
"tsc": "tsc --project tsconfig.json",
"watch": "tsc --watch --project tsconfig.json"
},
"author": "Laurent Czoic",
"author": "Laurent Cozic",
"license": "MIT",
"dependencies": {
"@joplin/fork-htmlparser2": "^4.1.35",

View File

@@ -4,7 +4,7 @@ const Setting = require('./models/Setting').default;
const { FileApi } = require('./file-api.js');
const Synchronizer = require('./Synchronizer').default;
const { FileApiDriverAmazonS3 } = require('./file-api-driver-amazon-s3.js');
const S3 = require('aws-sdk/clients/s3');
const { S3Client, HeadBucketCommand } = require('@aws-sdk/client-s3');
class SyncTargetAmazonS3 extends BaseSyncTarget {
static id() {
@@ -25,7 +25,7 @@ class SyncTargetAmazonS3 extends BaseSyncTarget {
}
static label() {
return `${_('AWS S3')} (Beta)`;
return `${_('S3')} (Beta)`;
}
static description() {
@@ -40,12 +40,17 @@ class SyncTargetAmazonS3 extends BaseSyncTarget {
return Setting.value('sync.8.path');
}
// These are the settings that get read from disk to instantiate the API.
s3AuthParameters() {
return {
accessKeyId: Setting.value('sync.8.username'),
secretAccessKey: Setting.value('sync.8.password'),
s3UseArnRegion: true, // override the request region with the region inferred from requested resource's ARN
s3ForcePathStyle: true,
// We need to set a region. See https://github.com/aws/aws-sdk-js-v3/issues/1845#issuecomment-754832210
region: Setting.value('sync.8.region'),
credentials: {
accessKeyId: Setting.value('sync.8.username'),
secretAccessKey: Setting.value('sync.8.password'),
},
UseArnRegion: true, // override the request region with the region inferred from requested resource's ARN.
forcePathStyle: Setting.value('sync.8.forcePathStyle'), // Older implementations may not support more modern access, so we expose this to allow people the option to toggle.
endpoint: Setting.value('sync.8.url'),
};
}
@@ -53,26 +58,45 @@ class SyncTargetAmazonS3 extends BaseSyncTarget {
api() {
if (this.api_) return this.api_;
this.api_ = new S3(this.s3AuthParameters());
this.api_ = new S3Client(this.s3AuthParameters());
// There is a bug with auto skew correction in aws-sdk-js-v3
// and this attempts to remove the skew correction for all calls.
// There are some additional spots in the app where we reset this
// to zero as well as it appears the skew logic gets triggered
// which makes "RequestTimeTooSkewed" errors...
// See https://github.com/aws/aws-sdk-js-v3/issues/2208
this.api_.config.systemClockOffset = 0;
return this.api_;
}
static async newFileApi_(syncTargetId, options) {
// These options are read from the form on the page
// so we can test new config choices without overriding the current settings.
const apiOptions = {
accessKeyId: options.username(),
secretAccessKey: options.password(),
s3UseArnRegion: true,
s3ForcePathStyle: true,
region: options.region(),
credentials: {
accessKeyId: options.username(),
secretAccessKey: options.password(),
},
UseArnRegion: true, // override the request region with the region inferred from requested resource's ARN.
forcePathStyle: options.forcePathStyle(),
endpoint: options.url(),
};
const api = new S3(apiOptions);
const api = new S3Client(apiOptions);
const driver = new FileApiDriverAmazonS3(api, SyncTargetAmazonS3.s3BucketName());
const fileApi = new FileApi('', driver);
fileApi.setSyncTargetId(syncTargetId);
return fileApi;
}
// With the aws-sdk-v3-js some errors (301/403) won't get their XML parsed properly.
// I think it's this issue: https://github.com/aws/aws-sdk-js-v3/issues/1596
// If you save the config on desktop, restart the app and attempt a sync, we should get a clearer error message because the sync logic has more robust XML error parsing.
// We could implement that here, but the above workaround saves some code.
static async checkConfig(options) {
const fileApi = await SyncTargetAmazonS3.newFileApi_(SyncTargetAmazonS3.id(), options);
fileApi.requestRepeatCount_ = 0;
@@ -81,22 +105,28 @@ class SyncTargetAmazonS3 extends BaseSyncTarget {
ok: false,
errorMessage: '',
};
try {
const headBucketReq = new Promise((resolve, reject) => {
fileApi.driver().api().headBucket({
Bucket: options.path(),
},(err, response) => {
if (err) reject(err);
else resolve(response);
});
fileApi.driver().api().send(
new HeadBucketCommand({
Bucket: options.path(),
}),(err, response) => {
if (err) reject(err);
else resolve(response);
});
});
const result = await headBucketReq;
if (!result) throw new Error(`AWS S3 bucket not found: ${SyncTargetAmazonS3.s3BucketName()}`);
output.ok = true;
} catch (error) {
output.errorMessage = error.message;
if (error.code) output.errorMessage += ` (Code ${error.code})`;
if (error.message) {
output.errorMessage = error.message;
}
if (error.code) {
output.errorMessage += ` (Code ${error.code})`;
}
}
return output;

View File

@@ -429,7 +429,7 @@ export default class Synchronizer {
// Before synchronising make sure all share_id properties are set
// correctly so as to share/unshare the right items.
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(this.resourceService());
const itemUploader = new ItemUploader(this.api(), this.apiCall);

View File

@@ -82,7 +82,6 @@ shared.saveSettings = function(comp) {
for (const key in comp.state.settings) {
if (!comp.state.settings.hasOwnProperty(key)) continue;
if (comp.state.changedSettingKeys.indexOf(key) < 0) continue;
console.info('Saving', key, comp.state.settings[key]);
Setting.setValue(key, comp.state.settings[key]);
}

View File

@@ -3,6 +3,9 @@ const { basename } = require('./path-utils');
const shim = require('./shim').default;
const JoplinError = require('./JoplinError').default;
const { Buffer } = require('buffer');
const { GetObjectCommand, ListObjectsV2Command, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const parser = require('fast-xml-parser');
const S3_MAX_DELETES = 1000;
@@ -26,43 +29,56 @@ class FileApiDriverAmazonS3 {
}
hasErrorCode_(error, errorCode) {
if (!error || typeof error.code !== 'string') return false;
return error.code.indexOf(errorCode) >= 0;
if (!error) return false;
if (error.name) {
return error.name.indexOf(errorCode) >= 0;
} else if (error.code) {
return error.code.indexOf(errorCode) >= 0;
} else if (error.Code) {
return error.Code.indexOf(errorCode) >= 0;
} else {
return false;
}
}
// Need to make a custom promise, built-in promise is broken: https://github.com/aws/aws-sdk-js/issues/1436
async s3GetObject(key) {
return new Promise((resolve, reject) => {
this.api().getObject({
Bucket: this.s3_bucket_,
Key: key,
}, (err, response) => {
if (err) reject(err);
else resolve(response);
});
// Because of the way AWS-SDK-v3 works for getting data from a bucket we will
// use a pre-signed URL to avoid https://github.com/aws/aws-sdk-js-v3/issues/1877
async s3GenerateGetURL(key) {
const signedUrl = await getSignedUrl(this.api(), new GetObjectCommand({
Bucket: this.s3_bucket_,
Key: key,
}), {
expiresIn: 3600,
});
return signedUrl;
}
// We've now moved to aws-sdk-v3 and this note is outdated, but explains the promise structure.
// Need to make a custom promise, built-in promise is broken: https://github.com/aws/aws-sdk-js/issues/1436
// TODO: Re-factor to https://github.com/aws/aws-sdk-js-v3/tree/main/clients/client-s3#asyncawait
async s3ListObjects(key, cursor) {
return new Promise((resolve, reject) => {
this.api().listObjectsV2({
this.api().send(new ListObjectsV2Command({
Bucket: this.s3_bucket_,
Prefix: key,
Delimiter: '/',
ContinuationToken: cursor,
}, (err, response) => {
}), (err, response) => {
if (err) reject(err);
else resolve(response);
});
});
}
async s3HeadObject(key) {
return new Promise((resolve, reject) => {
this.api().headObject({
this.api().send(new HeadObjectCommand({
Bucket: this.s3_bucket_,
Key: key,
}, (err, response) => {
}), (err, response) => {
if (err) reject(err);
else resolve(response);
});
@@ -71,11 +87,11 @@ class FileApiDriverAmazonS3 {
async s3PutObject(key, body) {
return new Promise((resolve, reject) => {
this.api().putObject({
this.api().send(new PutObjectCommand({
Bucket: this.s3_bucket_,
Key: key,
Body: body,
}, (err, response) => {
}), (err, response) => {
if (err) reject(err);
else resolve(response);
});
@@ -87,12 +103,12 @@ class FileApiDriverAmazonS3 {
const body = await shim.fsDriver().readFile(path, 'base64');
const fileStat = await shim.fsDriver().stat(path);
return new Promise((resolve, reject) => {
this.api().putObject({
this.api().send(new PutObjectCommand({
Bucket: this.s3_bucket_,
Key: key,
Body: Buffer.from(body, 'base64'),
ContentLength: `${fileStat.size}`,
}, (err, response) => {
}), (err, response) => {
if (err) reject(err);
else resolve(response);
});
@@ -101,10 +117,10 @@ class FileApiDriverAmazonS3 {
async s3DeleteObject(key) {
return new Promise((resolve, reject) => {
this.api().deleteObject({
this.api().send(new DeleteObjectCommand({
Bucket: this.s3_bucket_,
Key: key,
},
}),
(err, response) => {
if (err) {
console.log(err.code);
@@ -118,10 +134,10 @@ class FileApiDriverAmazonS3 {
// Assumes key is formatted, like `{Key: 's3 path'}`
async s3DeleteObjects(keys) {
return new Promise((resolve, reject) => {
this.api().deleteObjects({
this.api().send(new DeleteObjectsCommand({
Bucket: this.s3_bucket_,
Delete: { Objects: keys },
},
}),
(err, response) => {
if (err) {
console.log(err.code);
@@ -188,8 +204,20 @@ class FileApiDriverAmazonS3 {
prefixPath = `${prefixPath}/`;
}
// There is a bug/quirk of aws-sdk-js-v3 which causes the
// S3Client systemClockOffset to be wildly inaccurate. This
// effectively removes the offset and sets it to system time.
// See https://github.com/aws/aws-sdk-js-v3/issues/2208 for more.
// If the user's time actaully off, then this should correctly
// result in a RequestTimeTooSkewed error from s3ListObjects.
this.api().config.systemClockOffset = 0;
let response = await this.s3ListObjects(prefixPath);
// In aws-sdk-js-v3 if there are no contents it no longer returns
// an empty array. This creates an Empty array to pass onward.
if (response.Contents === undefined) response.Contents = [];
let output = this.metadataToStats_(response.Contents, prefixPath);
while (response.IsTruncated) {
@@ -212,41 +240,51 @@ class FileApiDriverAmazonS3 {
try {
let output = null;
const response = await this.s3GetObject(remotePath);
output = response.Body;
let response = null;
const s3Url = await this.s3GenerateGetURL(remotePath);
if (options.target === 'file') {
const filePath = options.path;
if (!filePath) throw new Error('get: target options.path is missing');
output = await shim.fetchBlob(s3Url, options);
} else if (responseFormat === 'text') {
response = await shim.fetch(s3Url, options);
// TODO: check if this ever hits on RN
await shim.fsDriver().writeBinaryFile(filePath, output);
return {
ok: true,
path: filePath,
text: () => {
return response.statusMessage;
},
json: () => {
return { message: `${response.statusCode}: ${response.statusMessage}` };
},
status: response.statusCode,
headers: response.headers,
};
}
if (responseFormat === 'text') {
output = output.toString();
output = await response.text();
// we need to make sure that errors get thrown as we are manually fetching above.
if (!response.ok) {
throw { name: response.statusText, output: output };
}
}
return output;
} catch (error) {
if (this.hasErrorCode_(error, 'NoSuchKey')) {
return null;
} else if (this.hasErrorCode_(error, 'AccessDenied')) {
throw new JoplinError('Do not have proper permissions to Bucket', 'rejectedByTarget');
// This means that the error was on the Desktop client side and we need to handle that.
// On Mobile it won't match because FetchError is a node-fetch feature.
// https://github.com/node-fetch/node-fetch/blob/main/docs/ERROR-HANDLING.md
if (error.name === 'FetchError') { throw error.message; }
let parsedOutput = '';
// If error.output is not xml the last else case should
// actually let us see the output of error.
if (error.output) {
parsedOutput = parser.parse(error.output);
if (this.hasErrorCode_(parsedOutput.Error, 'AuthorizationHeaderMalformed')) {
throw error.output;
}
if (this.hasErrorCode_(parsedOutput.Error, 'NoSuchKey')) {
return null;
} else if (this.hasErrorCode_(parsedOutput.Error, 'AccessDenied')) {
throw new JoplinError('Do not have proper permissions to Bucket', 'rejectedByTarget');
}
} else {
throw error;
if (error.output) {
throw error.output;
} else {
throw error;
}
}
}
}
@@ -308,13 +346,14 @@ class FileApiDriverAmazonS3 {
}
}
async move(oldPath, newPath) {
const req = new Promise((resolve, reject) => {
this.api().copyObject({
this.api().send(new CopyObjectCommand({
Bucket: this.s3_bucket_,
CopySource: this.makePath_(oldPath),
Key: newPath,
},(err, response) => {
}),(err, response) => {
if (err) reject(err);
else resolve(response);
});
@@ -333,6 +372,7 @@ class FileApiDriverAmazonS3 {
}
}
format() {
throw new Error('Not supported');
}
@@ -340,10 +380,10 @@ class FileApiDriverAmazonS3 {
async clearRoot() {
const listRecursive = async (cursor) => {
return new Promise((resolve, reject) => {
return this.api().listObjectsV2({
return this.api().send(new ListObjectsV2Command({
Bucket: this.s3_bucket_,
ContinuationToken: cursor,
}, (err, response) => {
}), (err, response) => {
if (err) reject(err);
else resolve(response);
});
@@ -351,6 +391,9 @@ class FileApiDriverAmazonS3 {
};
let response = await listRecursive();
// In aws-sdk-js-v3 if there are no contents it no longer returns
// an empty array. This creates an Empty array to pass onward.
if (response.Contents === undefined) response.Contents = [];
let keys = response.Contents.map((content) => content.Key);
while (response.IsTruncated) {

View File

@@ -1,7 +1,6 @@
class FsDriverDummy {
constructor() {}
appendFileSync() {}
writeBinaryFile() {}
readFile() {}
}

View File

@@ -26,16 +26,6 @@ export default class FsDriverNode extends FsDriverBase {
}
}
public async writeBinaryFile(path: string, content: any) {
try {
// let buffer = new Buffer(content);
const buffer = Buffer.from(content);
return await fs.writeFile(path, buffer);
} catch (error) {
throw this.fsErrorToJsError_(error, path);
}
}
public async writeFile(path: string, string: string, encoding: string = 'base64') {
try {
if (encoding === 'buffer') {

View File

@@ -1,4 +1,4 @@
import { setupDatabaseAndSynchronizer, switchClient, createFolderTree, supportDir, msleep } from '../testing/test-utils';
import { setupDatabaseAndSynchronizer, switchClient, createFolderTree, supportDir, msleep, resourceService } from '../testing/test-utils';
import Folder from '../models/Folder';
import { allNotesFolders } from '../testing/test-utils-synchronizer';
import Note from '../models/Note';
@@ -41,7 +41,7 @@ describe('models/Folder.sharing', function() {
]);
await Folder.save({ id: folder.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService());
const allItems = await allNotesFolders();
for (const item of allItems) {
@@ -87,7 +87,7 @@ describe('models/Folder.sharing', function() {
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService());
folder1 = await Folder.loadByTitle('folder 1');
const folder2 = await Folder.loadByTitle('folder 2');
@@ -121,7 +121,7 @@ describe('models/Folder.sharing', function() {
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService());
folder1 = await Folder.loadByTitle('folder 1');
let folder2 = await Folder.loadByTitle('folder 2');
@@ -133,7 +133,7 @@ describe('models/Folder.sharing', function() {
// Move the folder outside the shared folder
await Folder.save({ id: folder2.id, parent_id: folder3.id });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService());
folder2 = await Folder.loadByTitle('folder 2');
expect(folder2.share_id).toBe('');
@@ -141,7 +141,7 @@ describe('models/Folder.sharing', function() {
{
await Folder.save({ id: folder2.id, parent_id: folder1.id });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService());
folder2 = await Folder.loadByTitle('folder 2');
expect(folder2.share_id).toBe('abcd1234');
}
@@ -180,7 +180,7 @@ describe('models/Folder.sharing', function() {
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService());
const note1: NoteEntity = await Note.loadByTitle('note 1');
const note2: NoteEntity = await Note.loadByTitle('note 2');
@@ -210,7 +210,7 @@ describe('models/Folder.sharing', function() {
]);
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService());
const note1: NoteEntity = await Note.loadByTitle('note 1');
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
expect(note1.share_id).toBe('abcd1234');
@@ -218,7 +218,7 @@ describe('models/Folder.sharing', function() {
// Move the note outside of the shared folder
await Note.save({ id: note1.id, parent_id: folder2.id });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService());
{
const note1: NoteEntity = await Note.loadByTitle('note 1');
@@ -228,7 +228,7 @@ describe('models/Folder.sharing', function() {
// Move the note back inside the shared folder
await Note.save({ id: note1.id, parent_id: folder1.id });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService());
{
const note1: NoteEntity = await Note.loadByTitle('note 1');
@@ -256,7 +256,7 @@ describe('models/Folder.sharing', function() {
]);
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService());
let note1: NoteEntity = await Note.loadByTitle('note 1');
let note2: NoteEntity = await Note.loadByTitle('note 2');
@@ -266,7 +266,7 @@ describe('models/Folder.sharing', function() {
expect(note2.share_id).toBe('abcd1234');
await Note.save({ id: note1.id, parent_id: folder2.id });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService());
note1 = await Note.loadByTitle('note 1');
note2 = await Note.loadByTitle('note 2');
@@ -296,7 +296,7 @@ describe('models/Folder.sharing', function() {
]);
await Folder.save({ id: folder.id, share_id: 'abcd1234' });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService);
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
const note1: NoteEntity = await Note.loadByTitle('note 1');
@@ -312,9 +312,7 @@ describe('models/Folder.sharing', function() {
expect(resource.share_id).toBe('');
}
await Folder.updateAllShareIds();
// await NoteResource.updateResourceShareIds();
await Folder.updateAllShareIds(resourceService);
{
const resource: ResourceEntity = await Resource.load(resourceId);
@@ -324,7 +322,7 @@ describe('models/Folder.sharing', function() {
await Note.save({ id: note1.id, parent_id: folder2.id });
await resourceService.indexNoteResources();
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService);
{
const resource: ResourceEntity = await Resource.load(resourceId);
@@ -332,6 +330,103 @@ describe('models/Folder.sharing', function() {
}
});
it('should automatically duplicate resources when they are shared', async () => {
const resourceService = new ResourceService();
const folder1 = await createFolderTree('', [
{
title: 'folder 1', // SHARE 1
children: [
{
title: 'note 1',
},
{
title: 'note 2',
},
],
},
{
title: 'folder 2', // SHARE 2
children: [
{
title: 'note 3',
},
],
},
{
title: 'folder 3', // (not shared)
children: [
{
title: 'note 4',
},
],
},
]);
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
// await Folder.loadByTitle('folder 3');
let note1: NoteEntity = await Note.loadByTitle('note 1');
let note2: NoteEntity = await Note.loadByTitle('note 2');
let note3: NoteEntity = await Note.loadByTitle('note 3');
let note4: NoteEntity = await Note.loadByTitle('note 4');
await Folder.save({ id: folder1.id, share_id: 'share1' });
await Folder.save({ id: folder2.id, share_id: 'share2' });
note1 = await shim.attachFileToNote(note1, testImagePath);
note2 = await shim.attachFileToNote(note2, testImagePath);
note3 = await Note.save({ id: note3.id, body: note1.body });
note4 = await Note.save({ id: note4.id, body: note1.body });
const userUpdatedTimes: Record<string, number> = {
[note1.id]: note1.user_updated_time,
[note2.id]: note2.user_updated_time,
[note3.id]: note3.user_updated_time,
[note4.id]: note4.user_updated_time,
};
await msleep(1);
// We need to index the resources to populate the note_resources table
await resourceService.indexNoteResources();
await Folder.updateAllShareIds(resourceService);
// BEFORE:
//
// - Note 1 has resource 1 (share1)
// - Note 2 has resource 2 (share1)
// - Note 3 has resource 1 (share2)
// - Note 4 has resource 1 (not shared)
// AFTER:
//
// - Note 1 has resource 1 (share1)
// - Note 2 has resource 2 (share1)
// - Note 3 has resource 3 (share2)
// - Note 4 has resource 4 (not shared)
const resources = await Resource.all();
expect(resources.length).toBe(4);
note1 = await Note.load(note1.id);
note2 = await Note.load(note2.id);
note3 = await Note.load(note3.id);
note4 = await Note.load(note4.id);
expect(note1.body).not.toBe(note2.body);
expect(note1.body).not.toBe(note3.body);
expect(note1.body).not.toBe(note4.body);
expect(note2.body).not.toBe(note3.body);
expect(note2.body).not.toBe(note4.body);
expect(note3.body).not.toBe(note4.body);
expect(note1.user_updated_time).toBe(userUpdatedTimes[note1.id]);
expect(note2.user_updated_time).toBe(userUpdatedTimes[note2.id]);
expect(note3.user_updated_time).toBe(userUpdatedTimes[note3.id]);
expect(note4.user_updated_time).toBe(userUpdatedTimes[note4.id]);
});
it('should unshare items that are no longer part of an existing share', async () => {
await createFolderTree('', [
{
@@ -367,7 +462,7 @@ describe('models/Folder.sharing', function() {
await resourceService.indexNoteResources();
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(resourceService);
await Folder.updateNoLongerSharedItems(['1']);

View File

@@ -1,4 +1,4 @@
import { FolderEntity, FolderIcon } from '../services/database/types';
import { FolderEntity, FolderIcon, NoteEntity } from '../services/database/types';
import BaseModel, { DeleteOptions } from '../BaseModel';
import time from '../time';
import { _ } from '../locale';
@@ -9,6 +9,7 @@ import Resource from './Resource';
import { isRootSharedFolder } from '../services/share/reducer';
import Logger from '../Logger';
import syncDebugLog from '../services/synchronizer/syncDebugLog';
import ResourceService from '../services/ResourceService';
const { substrWithEllipsis } = require('../string-utils.js');
const logger = Logger.create('models/Folder');
@@ -368,36 +369,130 @@ export default class Folder extends BaseItem {
}
}
public static async updateResourceShareIds() {
// Find all resources where share_id is different from parent note
// share_id. Then update share_id on all these resources. Essentially it
// makes it match the resource share_id to the note share_id. At the
// same time we also process the is_shared property.
const rows = await this.db().selectAll(`
SELECT r.id, n.share_id, n.is_shared
FROM note_resources nr
LEFT JOIN resources r ON nr.resource_id = r.id
LEFT JOIN notes n ON nr.note_id = n.id
WHERE n.share_id != r.share_id
OR n.is_shared != r.is_shared
`);
public static async updateResourceShareIds(resourceService: ResourceService) {
// Updating the share_id property of the resources is complex because:
//
// The resource association to the note is done indirectly via the
// ResourceService
//
// And a given resource can appear inside multiple notes. However, for
// sharing we make the assumption that a resource can be part of only
// one share (one-to-one relationship because "share_id" is part of the
// "resources" table), which is usually the case. By copying and pasting
// note content from one note to another it's however possible to have
// the same resource in multiple shares (or in a non-shared and a shared
// folder).
//
// So in this function we take this into account - if a shared resource
// is part of multiple notes, we duplicate that resource so that each
// note has its own instance. When such duplication happens, we need to
// resume the process from the start (thus the loop) so that we deal
// with the right note/resource associations.
logger.debug('updateResourceShareIds: resources to update:', rows.length);
for (let i = 0; i < 5; i++) {
// Find all resources where share_id is different from parent note
// share_id. Then update share_id on all these resources. Essentially it
// makes it match the resource share_id to the note share_id. At the
// same time we also process the is_shared property.
for (const row of rows) {
await Resource.save({
id: row.id,
share_id: row.share_id || '',
is_shared: row.is_shared,
updated_time: Date.now(),
}, { autoTimestamp: false });
const rows = await this.db().selectAll(`
SELECT r.id, n.share_id, n.is_shared
FROM note_resources nr
LEFT JOIN resources r ON nr.resource_id = r.id
LEFT JOIN notes n ON nr.note_id = n.id
WHERE (
n.share_id != r.share_id
OR n.is_shared != r.is_shared
) AND nr.is_associated = 1
`);
if (!rows.length) return;
logger.debug('updateResourceShareIds: resources to update:', rows.length);
const resourceIds = rows.map(r => r.id);
interface Row {
resource_id: string;
note_id: string;
share_id: string;
}
// Now we check, for each resource, that it is associated with only
// one note. If it is not, we create duplicate resources so that
// each note has its own separate resource.
const noteResourceAssociations = await this.db().selectAll(`
SELECT resource_id, note_id, notes.share_id
FROM note_resources
LEFT JOIN notes ON notes.id = note_resources.note_id
WHERE resource_id IN ('${resourceIds.join('\',\'')}')
AND is_associated = 1
`) as Row[];
const resourceIdToNotes: Record<string, Row[]> = {};
for (const r of noteResourceAssociations) {
if (!resourceIdToNotes[r.resource_id]) resourceIdToNotes[r.resource_id] = [];
resourceIdToNotes[r.resource_id].push(r);
}
let hasCreatedResources = false;
for (const [resourceId, rows] of Object.entries(resourceIdToNotes)) {
if (rows.length <= 1) continue;
for (let i = 0; i < rows.length - 1; i++) {
const row = rows[i];
const note: NoteEntity = await Note.load(row.note_id);
if (!note) continue; // probably got deleted in the meantime?
const newResource = await Resource.duplicateResource(resourceId);
logger.info(`updateResourceShareIds: Automatically created resource "${newResource.id}" to replace resource "${resourceId}" because it is shared and duplicate across notes:`, row);
const regex = new RegExp(resourceId, 'gi');
const newBody = note.body.replace(regex, newResource.id);
await Note.save({
id: note.id,
body: newBody,
parent_id: note.parent_id,
updated_time: Date.now(),
}, {
autoTimestamp: false,
});
hasCreatedResources = true;
}
}
// If we have created resources, we refresh the note/resource
// associations using ResourceService and we resume the process.
// Normally, if the user didn't create any new notes or resources in
// the meantime, the second loop should find that each shared
// resource is associated with only one note.
if (hasCreatedResources) {
await resourceService.indexNoteResources();
continue;
} else {
// If all is good, we can set the share_id and is_shared
// property of the resource.
for (const row of rows) {
await Resource.save({
id: row.id,
share_id: row.share_id || '',
is_shared: row.is_shared,
updated_time: Date.now(),
}, { autoTimestamp: false });
}
return;
}
}
throw new Error('Failed to update resource share IDs');
}
public static async updateAllShareIds() {
public static async updateAllShareIds(resourceService: ResourceService) {
await this.updateFolderShareIds();
await this.updateNoteShareIds();
await this.updateResourceShareIds();
await this.updateResourceShareIds(resourceService);
}
// Clear the "share_id" property for the items that are associated with a

View File

@@ -7,6 +7,8 @@ import Folder from './Folder';
import Note from './Note';
import Tag from './Tag';
import ItemChange from './ItemChange';
import Resource from './Resource';
import { ResourceEntity } from '../services/database/types';
const ArrayUtils = require('../ArrayUtils.js');
async function allItems() {
@@ -143,6 +145,27 @@ describe('models/Note', function() {
expect(duplicatedNoteTags.length).toBe(2);
}));
it('should also duplicate resources when duplicating a note', (async () => {
const folder = await Folder.save({ title: 'folder' });
let note = await Note.save({ title: 'note', parent_id: folder.id });
await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
const resource = (await Resource.all())[0];
expect((await Resource.all()).length).toBe(1);
const duplicatedNote = await Note.duplicate(note.id);
const resources: ResourceEntity[] = await Resource.all();
expect(resources.length).toBe(2);
const duplicatedResource = resources.find(r => r.id !== resource.id);
note = await Note.load(note.id);
expect(note.body).toContain(resource.id);
expect(duplicatedNote.body).toContain(duplicatedResource.id);
}));
it('should delete a set of notes', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
const noOfNotes = 20;

View File

@@ -595,23 +595,36 @@ export default class Note extends BaseItem {
}
}
static async duplicate(noteId: string, options: any = null) {
private static async duplicateNoteResources(noteBody: string): Promise<string> {
const resourceIds = await this.linkedResourceIds(noteBody);
let newBody: string = noteBody;
for (const resourceId of resourceIds) {
const newResource = await Resource.duplicateResource(resourceId);
const regex = new RegExp(resourceId, 'gi');
newBody = newBody.replace(regex, newResource.id);
}
return newBody;
}
public static async duplicate(noteId: string, options: any = null) {
const changes = options && options.changes;
const uniqueTitle = options && options.uniqueTitle;
const originalNote = await Note.load(noteId);
const originalNote: NoteEntity = await Note.load(noteId);
if (!originalNote) throw new Error(`Unknown note: ${noteId}`);
const newNote = Object.assign({}, originalNote);
const fieldsToReset = ['id', 'created_time', 'updated_time', 'user_created_time', 'user_updated_time'];
for (const field of fieldsToReset) {
delete newNote[field];
delete (newNote as any)[field];
}
for (const n in changes) {
if (!changes.hasOwnProperty(n)) continue;
newNote[n] = changes[n];
(newNote as any)[n] = changes[n];
}
if (uniqueTitle) {
@@ -619,6 +632,8 @@ export default class Note extends BaseItem {
newNote.title = title;
}
newNote.body = await this.duplicateNoteResources(newNote.body);
const newNoteSaved = await this.save(newNote);
const originalTags = await Tag.tagsByNoteId(noteId);
for (const tagToAdd of originalTags) {

View File

@@ -246,10 +246,6 @@ export default class Resource extends BaseItem {
return this.fsDriver().readFile(this.fullPath(resource), 'Buffer');
}
static setContent(resource: ResourceEntity, content: any) {
return this.fsDriver().writeBinaryFile(this.fullPath(resource), content);
}
static isResourceUrl(url: string) {
return url && url.length === 34 && url[0] === ':' && url[1] === '/';
}
@@ -368,7 +364,7 @@ export default class Resource extends BaseItem {
return await this.fsDriver().readFile(Resource.fullPath(resource), encoding);
}
static async duplicateResource(resourceId: string) {
public static async duplicateResource(resourceId: string): Promise<ResourceEntity> {
const resource = await Resource.load(resourceId);
const localState = await Resource.localState(resource);

View File

@@ -503,7 +503,7 @@ class Setting extends BaseModel {
return value ? rtrimSlashes(value) : '';
},
public: true,
label: () => _('AWS S3 bucket'),
label: () => _('S3 bucket'),
description: () => emptyDirWarning,
storage: SettingStorage.File,
},
@@ -514,8 +514,25 @@ class Setting extends BaseModel {
show: (settings: any) => {
return settings['sync.target'] == SyncTargetRegistry.nameToId('amazon_s3');
},
filter: value => {
return value ? value.trim() : '';
},
public: true,
label: () => _('AWS S3 URL'),
label: () => _('S3 URL'),
storage: SettingStorage.File,
},
'sync.8.region': {
value: '',
type: SettingItemType.String,
section: 'sync',
show: (settings: any) => {
return settings['sync.target'] == SyncTargetRegistry.nameToId('amazon_s3');
},
filter: value => {
return value ? value.trim() : '';
},
public: true,
label: () => _('Region'),
storage: SettingStorage.File,
},
'sync.8.username': {
@@ -526,7 +543,7 @@ class Setting extends BaseModel {
return settings['sync.target'] == SyncTargetRegistry.nameToId('amazon_s3');
},
public: true,
label: () => _('AWS key'),
label: () => _('Access Key'),
storage: SettingStorage.File,
},
'sync.8.password': {
@@ -537,10 +554,20 @@ class Setting extends BaseModel {
return settings['sync.target'] == SyncTargetRegistry.nameToId('amazon_s3');
},
public: true,
label: () => _('AWS secret'),
label: () => _('Secret Key'),
secure: true,
},
'sync.8.forcePathStyle': {
value: false,
type: SettingItemType.Bool,
section: 'sync',
show: (settings: any) => {
return settings['sync.target'] == SyncTargetRegistry.nameToId('amazon_s3');
},
public: true,
label: () => _('Force Path Style'),
storage: SettingStorage.File,
},
'sync.9.path': {
value: '',
type: SettingItemType.String,

View File

@@ -1,4 +1,5 @@
import shim from './shim';
import time from './time';
const ntpClient_ = require('./vendor/ntp-client');
const server = {
@@ -25,12 +26,24 @@ export async function getNetworkTime(): Promise<Date> {
}
export async function getDeviceTimeDrift(): Promise<number> {
const maxTries = 3;
let tryCount = 0;
let ntpTime: Date = null;
try {
ntpTime = await getNetworkTime();
} catch (error) {
error.message = `Cannot retrieve the network time: ${error.message}`;
throw error;
while (true) {
tryCount++;
try {
ntpTime = await getNetworkTime();
break;
} catch (error) {
if (tryCount >= maxTries) {
error.message = `Cannot retrieve the network time: ${error.message}`;
throw error;
} else {
await time.msleep(tryCount * 1000);
}
}
}
return ntpTime.getTime() - Date.now();

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,8 @@
"@joplin/turndown": "^4.0.60",
"@joplin/turndown-plugin-gfm": "^1.0.42",
"async-mutex": "^0.1.3",
"aws-sdk": "^2.588.0",
"@aws-sdk/client-s3": "^3.34.0",
"@aws-sdk/s3-request-presigner": "^3.34.0",
"base-64": "^0.1.0",
"base64-stream": "^1.0.0",
"builtin-modules": "^3.1.0",
@@ -46,7 +47,6 @@
"diff-match-patch": "^1.0.4",
"es6-promise-pool": "^2.5.0",
"fast-deep-equal": "^3.1.3",
"file-uri-to-path": "^1.0.0",
"follow-redirects": "^1.2.4",
"form-data": "^2.1.4",
"fs-extra": "^5.0.0",

View File

@@ -340,3 +340,17 @@ export async function masterPasswordIsValid(masterPassword: string, activeMaster
// compare to whatever they've entered earlier.
return Setting.value('encryption.masterPassword') === masterPassword;
}
export async function masterKeysWithoutPassword(): Promise<string[]> {
const syncInfo = localSyncInfo();
const passwordCache = Setting.value('encryption.passwordCache');
const output: string[] = [];
for (const mk of syncInfo.masterKeys) {
if (!masterKeyEnabled(mk)) continue;
const password = await findMasterKeyPassword(EncryptionService.instance(), mk, passwordCache);
if (!password) output.push(mk.id);
}
return output;
}

View File

@@ -25,7 +25,7 @@ const urlUtils = require('../../../urlUtils.js');
const ArrayUtils = require('../../../ArrayUtils.js');
const { mimeTypeFromHeaders } = require('../../../net-utils');
const { fileExtension, safeFileExtension, safeFilename, filename } = require('../../../path-utils');
const uri2path = require('file-uri-to-path');
const { fileUriToPath } = require('../../../urlUtils');
const { MarkupToHtml } = require('@joplin/renderer');
const { ErrorNotFound } = require('../utils/errors');
@@ -178,7 +178,7 @@ async function downloadImage(url: string /* , allowFileProtocolImages */) {
} else if (urlUtils.urlProtocol(url).toLowerCase() === 'file:') {
// Can't think of any reason to disallow this at this point
// if (!allowFileProtocolImages) throw new Error('For security reasons, this URL with file:// protocol cannot be downloaded');
const localPath = uri2path(url);
const localPath = fileUriToPath(url);
await shim.fsDriver().copy(localPath, imagePath);
} else {
const response = await shim.fetchBlob(url, { path: imagePath, maxRetry: 1 });

View File

@@ -11,6 +11,7 @@ import EncryptionService from '../e2ee/EncryptionService';
import { PublicPrivateKeyPair, mkReencryptFromPasswordToPublicKey, mkReencryptFromPublicKeyToPassword } from '../e2ee/ppk';
import { MasterKeyEntity } from '../e2ee/types';
import { getMasterPassword } from '../e2ee/utils';
import ResourceService from '../ResourceService';
import { addMasterKey, getEncryptionEnabled, localSyncInfo } from '../synchronizer/syncInfoUtils';
import { ShareInvitation, State, stateRootKey, StateShare } from './reducer';
@@ -122,7 +123,7 @@ export default class ShareService {
// Note: race condition if the share is created but the app crashes
// before setting share_id on the folder. See unshareFolder() for info.
await Folder.save({ id: folder.id, share_id: share.id });
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(ResourceService.instance());
return share;
}
@@ -167,7 +168,7 @@ export default class ShareService {
// It's ok if updateAllShareIds() doesn't run because it's executed on
// each sync too.
await Folder.updateAllShareIds();
await Folder.updateAllShareIds(ResourceService.instance());
}
// This is when a share recipient decides to leave the shared folder.

View File

@@ -59,7 +59,7 @@ import Synchronizer from '../Synchronizer';
import SyncTargetNone from '../SyncTargetNone';
import { setRSA } from '../services/e2ee/ppk';
const md5 = require('md5');
const S3 = require('aws-sdk/clients/s3');
const { S3Client } = require('@aws-sdk/client-s3');
const { Dirnames } = require('../services/synchronizer/utils/types');
import RSA from '../services/e2ee/RSA.node';
@@ -602,10 +602,16 @@ async function initFileApi() {
const appDir = await api.appDirectory();
fileApi = new FileApi(appDir, new FileApiDriverOneDrive(api));
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('amazon_s3')) {
// We make sure for S3 tests run in band because tests
// share the same directory which will cause locking errors.
mustRunInBand();
const amazonS3CredsPath = `${oldTestDir}/support/amazon-s3-auth.json`;
const amazonS3Creds = require(amazonS3CredsPath);
if (!amazonS3Creds || !amazonS3Creds.accessKeyId) throw new Error(`AWS auth JSON missing in ${amazonS3CredsPath} format should be: { "accessKeyId": "", "secretAccessKey": "", "bucket": "mybucket"}`);
const api = new S3({ accessKeyId: amazonS3Creds.accessKeyId, secretAccessKey: amazonS3Creds.secretAccessKey, s3UseArnRegion: true });
if (!amazonS3Creds || !amazonS3Creds.credentials) throw new Error(`AWS auth JSON missing in ${amazonS3CredsPath} format should be: { "credentials": { "accessKeyId": "", "secretAccessKey": "", } "bucket": "mybucket", region: "", forcePathStyle: ""}`);
const api = new S3Client({ region: amazonS3Creds.region, credentials: amazonS3Creds.credentials, s3UseArnRegion: true, forcePathStyle: amazonS3Creds.forcePathStyle, endpoint: amazonS3Creds.endpoint });
fileApi = new FileApi('', new FileApiDriverAmazonS3(api, amazonS3Creds.bucket));
} else if (syncTargetId_ == SyncTargetRegistry.nameToId('joplinServer')) {
mustRunInBand();

View File

@@ -105,4 +105,101 @@ urlUtils.objectToQueryString = function(query) {
return queryString;
};
// This is a modified version of the file-uri-to-path package:
//
// - It removes the dependency to the "path" package, which wouldn't work with
// React Native.
//
// - It always returns paths with forward slashes "/". This is normally handled
// properly everywhere.
//
// - Adds the "platform" parameter to optionall return paths with "\" for win32
function fileUriToPath_(uri, platform) {
const sep = '/';
if (
typeof uri !== 'string' ||
uri.length <= 7 ||
uri.substring(0, 7) !== 'file://'
) {
throw new TypeError(
'must pass in a file:// URI to convert to a file path'
);
}
const rest = decodeURI(uri.substring(7));
const firstSlash = rest.indexOf('/');
let host = rest.substring(0, firstSlash);
let path = rest.substring(firstSlash + 1);
// 2. Scheme Definition
// As a special case, <host> can be the string "localhost" or the empty
// string; this is interpreted as "the machine from which the URL is
// being interpreted".
if (host === 'localhost') {
host = '';
}
if (host) {
host = sep + sep + host;
}
// 3.2 Drives, drive letters, mount points, file system root
// Drive letters are mapped into the top of a file URI in various ways,
// depending on the implementation; some applications substitute
// vertical bar ("|") for the colon after the drive letter, yielding
// "file:///c|/tmp/test.txt". In some cases, the colon is left
// unchanged, as in "file:///c:/tmp/test.txt". In other cases, the
// colon is simply omitted, as in "file:///c/tmp/test.txt".
path = path.replace(/^(.+)\|/, '$1:');
// for Windows, we need to invert the path separators from what a URI uses
// if (sep === '\\') {
// path = path.replace(/\//g, '\\');
// }
if (/^.+:/.test(path)) {
// has Windows drive at beginning of path
} else {
// unix path…
path = sep + path;
}
if (platform === 'win32') {
return (host + path).replace(/\//g, '\\');
} else {
return host + path;
}
}
urlUtils.fileUriToPath = (path, platform = 'linux') => {
const output = fileUriToPath_(path, platform);
// The file-uri-to-path module converts Windows path such as
//
// file://c:/autoexec.bat => \\c:\autoexec.bat
//
// Probably because a file:// that starts with only two slashes is not
// quite valid. If we use three slashes, it works:
//
// file:///c:/autoexec.bat => c:\autoexec.bat
//
// However there are various places in the app where we can find
// paths with only two slashes because paths are often constructed
// as `file://${resourcePath}` - which works in all OSes except
// Windows.
//
// So here we introduce a special case - if we detect that we have
// an invalid Windows path that starts with \\x:, we just remove
// the first two backslashes.
//
// https://github.com/laurent22/joplin/issues/5693
if (output.match(/^\/\/[a-zA-Z]:/)) {
return output.substr(2);
}
return output;
};
module.exports = urlUtils;

View File

@@ -71,4 +71,30 @@ describe('urlUtils', function() {
}
}));
it('should convert a file URI to a file path', (async () => {
// Tests imported from https://github.com/TooTallNate/file-uri-to-path/tree/master/test
const testCases = {
'file://host/path': '//host/path',
'file://localhost/etc/fstab': '/etc/fstab',
'file:///etc/fstab': '/etc/fstab',
'file:///c:/WINDOWS/clock.avi': 'c:/WINDOWS/clock.avi',
'file://localhost/c|/WINDOWS/clock.avi': 'c:/WINDOWS/clock.avi',
'file:///c|/WINDOWS/clock.avi': 'c:/WINDOWS/clock.avi',
'file://localhost/c:/WINDOWS/clock.avi': 'c:/WINDOWS/clock.avi',
'file://hostname/path/to/the%20file.txt': '//hostname/path/to/the file.txt',
'file:///c:/path/to/the%20file.txt': 'c:/path/to/the file.txt',
'file:///C:/Documents%20and%20Settings/davris/FileSchemeURIs.doc': 'C:/Documents and Settings/davris/FileSchemeURIs.doc',
'file:///C:/caf%C3%A9/%C3%A5r/d%C3%BCnn/%E7%89%9B%E9%93%83/Ph%E1%BB%9F/%F0%9F%98%B5.exe': 'C:/café/år/dünn/牛铃/Phở/😵.exe',
};
for (const [input, expected] of Object.entries(testCases)) {
const actual = urlUtils.fileUriToPath(input);
expect(actual).toBe(expected);
}
expect(urlUtils.fileUriToPath('file://c:/not/quite/right')).toBe('c:/not/quite/right');
expect(urlUtils.fileUriToPath('file:///d:/better')).toBe('d:/better');
expect(urlUtils.fileUriToPath('file:///c:/AUTOEXEC.BAT', 'win32')).toBe('c:\\AUTOEXEC.BAT');
}));
});

View File

@@ -1,12 +1,12 @@
{
"name": "@joplin/server",
"version": "2.6.11",
"version": "2.6.13",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@joplin/server",
"version": "2.6.11",
"version": "2.6.13",
"dependencies": {
"@aws-sdk/client-s3": "^3.40.0",
"@fortawesome/fontawesome-free": "^5.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/server",
"version": "2.6.11",
"version": "2.6.13",
"private": true,
"scripts": {
"start-dev": "npm run build && JOPLIN_IS_TESTING=1 nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",

View File

@@ -251,12 +251,14 @@ async function main() {
appLogger().info(`Starting server v${config().appVersion} (${env}) on port ${config().port} and PID ${process.pid}...`);
const timeDrift = await getDeviceTimeDrift();
if (Math.abs(timeDrift) > config().maxTimeDrift) {
throw new Error(`The device time drift is ${timeDrift}ms (Max allowed: ${config().maxTimeDrift}ms) - cannot continue as it could cause data loss and conflicts on the sync clients. You may increase env var MAX_TIME_DRIFT to pass the check.`);
if (config().maxTimeDrift) {
const timeDrift = await getDeviceTimeDrift();
if (Math.abs(timeDrift) > config().maxTimeDrift) {
throw new Error(`The device time drift is ${timeDrift}ms (Max allowed: ${config().maxTimeDrift}ms) - cannot continue as it could cause data loss and conflicts on the sync clients. You may increase env var MAX_TIME_DRIFT to pass the check.`);
}
appLogger().info(`NTP time offset: ${timeDrift}ms`);
}
appLogger().info(`NTP time offset: ${timeDrift}ms`);
appLogger().info('Running in Docker:', runningInDocker());
appLogger().info('Public base URL:', config().baseUrl);
appLogger().info('API base URL:', config().apiBaseUrl);

View File

@@ -6,14 +6,14 @@ interface Argv {
ttl: number;
}
export default class DeleteOldChangesCommand extends BaseCommand {
export default class CompressOldChangesCommand extends BaseCommand {
public command() {
return 'delete-old-changes';
return 'compress-old-changes';
}
public description() {
return 'deletes old changes';
return 'compresses old changes by discarding consecutive updates';
}
public options(): Record<string, Options> {
@@ -26,7 +26,7 @@ export default class DeleteOldChangesCommand extends BaseCommand {
}
public async run(argv: Argv, runContext: RunContext): Promise<void> {
await runContext.models.change().deleteOldChanges(argv.ttl ? argv.ttl * Day : null);
await runContext.models.change().compressOldChanges(argv.ttl ? argv.ttl * Day : null);
}
}

View File

@@ -9,12 +9,14 @@ const logger = Logger.create('ImportContentCommand');
enum ArgvCommand {
Import = 'import',
CheckConnection = 'check-connection',
DeleteDatabaseContentColumn = 'delete-database-content-col',
}
interface Argv {
command: ArgvCommand;
connection: string;
batchSize?: number;
maxContentSize?: number;
}
export default class StorageCommand extends BaseCommand {
@@ -34,6 +36,7 @@ export default class StorageCommand extends BaseCommand {
choices: [
ArgvCommand.Import,
ArgvCommand.CheckConnection,
ArgvCommand.DeleteDatabaseContentColumn,
],
},
};
@@ -45,6 +48,10 @@ export default class StorageCommand extends BaseCommand {
type: 'number',
description: 'Item batch size',
},
'max-content-size': {
type: 'number',
description: 'Max content size',
},
'connection': {
description: 'storage connection string',
type: 'string',
@@ -53,25 +60,38 @@ export default class StorageCommand extends BaseCommand {
}
public async run(argv: Argv, runContext: RunContext): Promise<void> {
const batchSize = argv.batchSize || 1000;
const commands: Record<ArgvCommand, Function> = {
[ArgvCommand.Import]: async () => {
if (!argv.connection) throw new Error('--connection option is required');
const toStorageConfig = parseStorageConnectionString(argv.connection);
const batchSize = argv.batchSize || 1000;
const maxContentSize = argv.maxContentSize || 200000000;
logger.info('Importing to storage:', toStorageConfig);
logger.info(`Batch size: ${batchSize}`);
logger.info(`Max content size: ${maxContentSize}`);
await runContext.models.item().importContentToStorage(toStorageConfig, {
batchSize: batchSize || 1000,
logger: logger as Logger,
batchSize,
maxContentSize,
logger,
});
},
[ArgvCommand.CheckConnection]: async () => {
logger.info(await storageConnectionCheck(argv.connection, runContext.db, runContext.models));
},
[ArgvCommand.DeleteDatabaseContentColumn]: async () => {
logger.info(`Batch size: ${batchSize}`);
await runContext.models.item().deleteDatabaseContentColumn({
batchSize,
logger,
});
},
};
await commands[argv.command]();

View File

@@ -47,6 +47,10 @@ export interface DbConfigConnection {
password?: string;
}
export interface QueryContext {
uniqueConstraintErrorLoggingDisabled?: boolean;
}
export interface KnexDatabaseConfig {
client: string;
connection: DbConfigConnection;
@@ -204,6 +208,7 @@ interface KnexQueryErrorResponse {
interface KnexQueryErrorData {
bindings: any[];
queryContext: QueryContext;
}
export async function connectDb(dbConfig: DatabaseConfig): Promise<DbConnection> {
@@ -214,6 +219,16 @@ export async function connectDb(dbConfig: DatabaseConfig): Promise<DbConnection>
}
connection.on('query-error', (response: KnexQueryErrorResponse, data: KnexQueryErrorData) => {
// It is possible to set certain properties on the query context to
// disable this handler. This is useful for example for constraint
// errors which are often already handled application side.
if (data.queryContext) {
if (data.queryContext.uniqueConstraintErrorLoggingDisabled && isUniqueConstraintError(response)) {
return;
}
}
const msg: string[] = [];
msg.push(response.message);
if (data.bindings && data.bindings.length) msg.push(JSON.stringify(filterBindings(data.bindings), null, ' '));

View File

@@ -16,7 +16,15 @@ const defaultEnvValues: EnvVariables = {
ERROR_STACK_TRACES: false,
COOKIES_SECURE: false,
RUNNING_IN_DOCKER: false,
MAX_TIME_DRIFT: 10,
// Maxiumm allowed drift between NTP time and server time. A few
// milliseconds is normally not an issue unless many clients are modifying
// the same note at the exact same time. But past a certain limit, it might
// mean the server clock is incorrect and should be fixed, as that could
// result in clients generating many conflicts. Set to 0 to disable the
// check. https://github.com/laurent22/joplin/issues/5738
MAX_TIME_DRIFT: 100,
// ==================================================
// URL config

View File

@@ -1,5 +1,5 @@
import { WithDates, WithUuid, databaseSchema, ItemType, Uuid, User } from '../services/database/types';
import { DbConnection } from '../db';
import { DbConnection, QueryContext } from '../db';
import TransactionHandler from '../utils/TransactionHandler';
import uuidgen from '../utils/uuidgen';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
@@ -24,6 +24,7 @@ export interface SaveOptions {
skipValidation?: boolean;
validationRules?: any;
previousItem?: any;
queryContext?: QueryContext;
}
export interface LoadOptions {
@@ -297,12 +298,12 @@ export default abstract class BaseModel<T> {
await this.withTransaction(async () => {
if (isNew) {
await this.db(this.tableName).insert(toSave);
await this.db(this.tableName).insert(toSave).queryContext(options.queryContext || {});
} else {
const objectId: string = (toSave as WithUuid).id;
if (!objectId) throw new Error('Missing "id" property');
delete (toSave as WithUuid).id;
const updatedCount: number = await this.db(this.tableName).update(toSave).where({ id: objectId });
const updatedCount: number = await this.db(this.tableName).update(toSave).where({ id: objectId }).queryContext(options.queryContext || {});
(toSave as WithUuid).id = objectId;
// Sanity check:

View File

@@ -227,13 +227,13 @@ describe('ChangeModel', function() {
expect(await models().change().count()).toBe(7);
// Shouldn't do anything initially because it only deletes old changes.
await models().change().deleteOldChanges();
await models().change().compressOldChanges();
expect(await models().change().count()).toBe(7);
// 180 days after T4, it should delete all U1 updates events except for
// the last one
jest.setSystemTime(new Date(t4 + changeTtl).getTime());
await models().change().deleteOldChanges();
await models().change().compressOldChanges();
expect(await models().change().count()).toBe(5);
{
const updateChange = (await models().change().all()).find(c => c.item_id === note1.id && c.type === ChangeType.Update);
@@ -247,13 +247,13 @@ describe('ChangeModel', function() {
// there's only one note 2 change that is older than 90 days at this
// point.
jest.setSystemTime(new Date(t5 + changeTtl).getTime());
await models().change().deleteOldChanges();
await models().change().compressOldChanges();
expect(await models().change().count()).toBe(5);
// After T6, more than 90 days later - now the change at T5 should be
// deleted, keeping only the change at T6.
jest.setSystemTime(new Date(t6 + changeTtl).getTime());
await models().change().deleteOldChanges();
await models().change().compressOldChanges();
expect(await models().change().count()).toBe(4);
{
const updateChange = (await models().change().all()).find(c => c.item_id === note2.id && c.type === ChangeType.Update);

View File

@@ -309,7 +309,9 @@ export default class ChangeModel extends BaseModel<Change> {
return output;
}
public async deleteOldChanges(ttl: number = null) {
// See spec for complete documentation:
// https://joplinapp.org/spec/server_delta_sync/#regarding-the-deletion-of-old-change-events
public async compressOldChanges(ttl: number = null) {
ttl = ttl === null ? defaultChangeTtl : ttl;
const cutOffDate = Date.now() - ttl;
const limit = 1000;
@@ -324,7 +326,7 @@ export default class ChangeModel extends BaseModel<Change> {
let error: Error = null;
let totalDeletedCount = 0;
logger.info(`deleteOldChanges: Processing changes older than: ${formatDateTime(cutOffDate)} (${cutOffDate})`);
logger.info(`compressOldChanges: Processing changes older than: ${formatDateTime(cutOffDate)} (${cutOffDate})`);
while (true) {
// First get all the UPDATE changes before the specified date, and
@@ -373,14 +375,14 @@ export default class ChangeModel extends BaseModel<Change> {
totalDeletedCount += deletedCount;
doneItemIds.push(row.item_id);
}
}, 'ChangeModel::deleteOldChanges');
}, 'ChangeModel::compressOldChanges');
logger.info(`deleteOldChanges: Processed: ${doneItemIds.length} items. Deleted: ${totalDeletedCount} changes.`);
logger.info(`compressOldChanges: Processed: ${doneItemIds.length} items. Deleted: ${totalDeletedCount} changes.`);
if (error) throw error;
}
logger.info(`deleteOldChanges: Finished processing. Done ${doneItemIds.length} items. Deleted: ${totalDeletedCount} changes.`);
logger.info(`compressOldChanges: Finished processing. Done ${doneItemIds.length} items. Deleted: ${totalDeletedCount} changes.`);
}
public async save(change: Change, options: SaveOptions = {}): Promise<Change> {

View File

@@ -7,6 +7,7 @@ import config from '../config';
import { msleep } from '../utils/time';
import loadStorageDriver from './items/storage/loadStorageDriver';
import { ErrorPayloadTooLarge } from '../utils/errors';
import { isSqlite } from '../db';
describe('ItemModel', function() {
@@ -301,9 +302,7 @@ describe('ItemModel', function() {
await expectNotThrow(async () => models.item().loadWithContent(item.id));
});
test('should allow importing content to item storage', async function() {
const { user: user1 } = await createUserAndSession(1);
const setupImportContentTest = async () => {
const tempDir1 = await tempDir('storage1');
const tempDir2 = await tempDir('storage2');
@@ -312,20 +311,52 @@ describe('ItemModel', function() {
path: tempDir1,
};
const models = newModelFactory(db(), {
const toStorageConfig = {
type: StorageDriverType.Filesystem,
path: tempDir2,
};
const fromModels = newModelFactory(db(), {
...config(),
storageDriver: fromStorageConfig,
});
await models.item().saveFromRawContent(user1, {
const toModels = newModelFactory(db(), {
...config(),
storageDriver: toStorageConfig,
});
const fromDriver = await loadStorageDriver(fromStorageConfig, db());
const toDriver = await loadStorageDriver(toStorageConfig, db());
return {
fromStorageConfig,
toStorageConfig,
fromModels,
toModels,
fromDriver,
toDriver,
};
};
test('should allow importing content to item storage', async function() {
const { user: user1 } = await createUserAndSession(1);
const {
toStorageConfig,
fromModels,
fromDriver,
toDriver,
} = await setupImportContentTest();
await fromModels.item().saveFromRawContent(user1, {
body: Buffer.from(JSON.stringify({ 'version': 1 })),
name: 'info.json',
});
const itemBefore = (await models.item().all())[0];
const itemBefore = (await fromModels.item().all())[0];
const fromDriver = await loadStorageDriver(fromStorageConfig, db());
const fromContent = await fromDriver.read(itemBefore.id, { models });
const fromContent = await fromDriver.read(itemBefore.id, { models: fromModels });
expect(fromContent.toString()).toBe('{"version":1}');
@@ -333,11 +364,6 @@ describe('ItemModel', function() {
await msleep(2);
const toStorageConfig = {
type: StorageDriverType.Filesystem,
path: tempDir2,
};
const toModels = newModelFactory(db(), {
...config(),
storageDriver: toStorageConfig,
@@ -350,24 +376,92 @@ describe('ItemModel', function() {
const itemBefore2 = result['info2.json'].item;
await models.item().importContentToStorage(toStorageConfig);
await fromModels.item().importContentToStorage(toStorageConfig);
const itemAfter = (await models.item().all()).find(it => it.id === itemBefore.id);
const itemAfter = (await fromModels.item().all()).find(it => it.id === itemBefore.id);
expect(itemAfter.content_storage_id).toBe(2);
expect(itemAfter.updated_time).toBe(itemBefore.updated_time);
// Just check the second item has not been processed since it was
// already on the right storage
const itemAfter2 = (await models.item().all()).find(it => it.id === itemBefore2.id);
const itemAfter2 = (await fromModels.item().all()).find(it => it.id === itemBefore2.id);
expect(itemAfter2.content_storage_id).toBe(2);
expect(itemAfter2.updated_time).toBe(itemBefore2.updated_time);
const toDriver = await loadStorageDriver(toStorageConfig, db());
const toContent = await toDriver.read(itemAfter.id, { models });
const toContent = await toDriver.read(itemAfter.id, { models: fromModels });
expect(toContent.toString()).toBe(fromContent.toString());
});
test('should skip large items when importing content to item storage', async function() {
const { user: user1 } = await createUserAndSession(1);
const {
toStorageConfig,
fromModels,
fromDriver,
toDriver,
} = await setupImportContentTest();
const result = await fromModels.item().saveFromRawContent(user1, {
body: Buffer.from(JSON.stringify({ 'version': 1 })),
name: 'info.json',
});
const itemId = result['info.json'].item.id;
expect(await fromDriver.exists(itemId, { models: fromModels })).toBe(true);
await fromModels.item().importContentToStorage(toStorageConfig, {
maxContentSize: 1,
});
expect(await toDriver.exists(itemId, { models: fromModels })).toBe(false);
await fromModels.item().importContentToStorage(toStorageConfig, {
maxContentSize: 999999,
});
expect(await toDriver.exists(itemId, { models: fromModels })).toBe(true);
});
test('should delete the database item content', async function() {
if (isSqlite(db())) {
expect(1).toBe(1);
return;
}
const { user: user1 } = await createUserAndSession(1);
await createItemTree3(user1.id, '', '', [
{
id: '000000000000000000000000000000F1',
children: [
{ id: '00000000000000000000000000000001' },
],
},
]);
const folder1 = await models().item().loadByName(user1.id, '000000000000000000000000000000F1.md');
const note1 = await models().item().loadByName(user1.id, '00000000000000000000000000000001.md');
await msleep(1);
expect(await models().item().dbContent(folder1.id)).not.toEqual(Buffer.from(''));
expect(await models().item().dbContent(note1.id)).not.toEqual(Buffer.from(''));
await models().item().deleteDatabaseContentColumn({ batchSize: 1 });
const folder1_v2 = await models().item().loadByName(user1.id, '000000000000000000000000000000F1.md');
const note1_v2 = await models().item().loadByName(user1.id, '00000000000000000000000000000001.md');
expect(folder1.updated_time).toBe(folder1_v2.updated_time);
expect(note1.updated_time).toBe(note1_v2.updated_time);
expect(await models().item().dbContent(folder1.id)).toEqual(Buffer.from(''));
expect(await models().item().dbContent(note1.id)).toEqual(Buffer.from(''));
});
// test('should stop importing item if it has been deleted', async function() {
// const { user: user1 } = await createUserAndSession(1);

View File

@@ -13,7 +13,7 @@ import { Config, StorageDriverConfig, StorageDriverMode } from '../utils/types';
import { NewModelFactoryHandler } from './factory';
import loadStorageDriver from './items/storage/loadStorageDriver';
import { msleep } from '../utils/time';
import Logger from '@joplin/lib/Logger';
import Logger, { LoggerWrapper } from '@joplin/lib/Logger';
import prettyBytes = require('pretty-bytes');
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
@@ -23,7 +23,13 @@ const extractNameRegex = /^root:\/(.*):$/;
export interface ImportContentToStorageOptions {
batchSize?: number;
logger?: Logger;
maxContentSize?: number;
logger?: Logger | LoggerWrapper;
}
export interface DeleteDatabaseContentOptions {
batchSize?: number;
logger?: Logger | LoggerWrapper;
}
export interface SaveFromRawContentItem {
@@ -191,11 +197,15 @@ export default class ItemModel extends BaseModel<Item> {
const storageDriver = await this.storageDriver();
const storageDriverFallback = await this.storageDriverFallback();
if (await storageDriver.exists(itemId, context)) {
if (!storageDriverFallback) {
return storageDriver.read(itemId, context);
} else {
if (!storageDriverFallback) throw new Error(`Content does not exist but fallback content driver is not defined: ${itemId}`);
return storageDriverFallback.read(itemId, context);
if (await storageDriver.exists(itemId, context)) {
return storageDriver.read(itemId, context);
} else {
if (!storageDriverFallback) throw new Error(`Content does not exist but fallback content driver is not defined: ${itemId}`);
return storageDriverFallback.read(itemId, context);
}
}
}
@@ -282,7 +292,7 @@ export default class ItemModel extends BaseModel<Item> {
}
}
private async atomicMoveContent(item: Item, toDriver: StorageDriverBase, drivers: Record<number, StorageDriverBase>, logger: Logger) {
private async atomicMoveContent(item: Item, toDriver: StorageDriverBase, drivers: Record<number, StorageDriverBase>, logger: Logger | LoggerWrapper) {
for (let i = 0; i < 10; i++) {
let fromDriver: StorageDriverBase = drivers[item.content_storage_id];
@@ -338,6 +348,7 @@ export default class ItemModel extends BaseModel<Item> {
public async importContentToStorage(toStorageConfig: StorageDriverConfig | StorageDriverBase, options: ImportContentToStorageOptions = null) {
options = {
batchSize: 1000,
maxContentSize: 200000000,
logger: new Logger(),
...options,
};
@@ -350,23 +361,37 @@ export default class ItemModel extends BaseModel<Item> {
.where('content_storage_id', '!=', toStorageDriver.storageId)
.first())['total'];
const skippedItemIds: Uuid[] = [];
let totalDone = 0;
while (true) {
const items: Item[] = await this
const query = this
.db(this.tableName)
.select(['id', 'content_storage_id', 'updated_time'])
.where('content_storage_id', '!=', toStorageDriver.storageId)
.limit(options.batchSize);
.select(['id', 'content_storage_id', 'content_size', 'updated_time'])
.where('content_storage_id', '!=', toStorageDriver.storageId);
if (skippedItemIds.length) void query.whereNotIn('id', skippedItemIds);
void query.limit(options.batchSize);
const items: Item[] = await query;
options.logger.info(`Processing items ${totalDone} / ${itemCount}`);
if (!items.length) {
options.logger.info(`All items have been processed. Total: ${totalDone}`);
options.logger.info(`Skipped items: ${skippedItemIds.join(', ')}`);
return;
}
for (const item of items) {
if (item.content_size > options.maxContentSize) {
options.logger.warn(`Skipped item "${item.id}" (Size: ${prettyBytes(item.content_size)}) because it is over the size limit (${prettyBytes(options.maxContentSize)})`);
skippedItemIds.push(item.id);
continue;
}
try {
await this.atomicMoveContent(item, toStorageDriver, fromDrivers, options.logger);
} catch (error) {
@@ -378,6 +403,52 @@ export default class ItemModel extends BaseModel<Item> {
}
}
public async deleteDatabaseContentColumn(options: DeleteDatabaseContentOptions = null) {
if (!returningSupported(this.db)) throw new Error('Not supported by this database driver');
options = {
batchSize: 1000,
logger: new Logger(),
...options,
};
const itemCount = (await this.db(this.tableName)
.count('id', { as: 'total' })
.where('content', '!=', Buffer.from(''))
.first())['total'];
let totalDone = 0;
// UPDATE items SET content = '\x' WHERE id IN (SELECT id FROM items WHERE content != '\x' LIMIT 5000);
while (true) {
options.logger.info(`Processing items ${totalDone} / ${itemCount}`);
const updatedRows = await this
.db(this.tableName)
.update({ content: Buffer.from('') }, ['id'])
.whereIn('id', this.db(this.tableName)
.select(['id'])
.where('content', '!=', Buffer.from(''))
.limit(options.batchSize)
);
totalDone += updatedRows.length;
if (!updatedRows.length) {
options.logger.info(`All items have been processed. Total: ${totalDone}`);
return;
}
await msleep(1000);
}
}
public async dbContent(itemId: Uuid): Promise<Buffer> {
const row: Item = await this.db(this.tableName).select(['content']).where('id', itemId).first();
return row.content;
}
public async sharedFolderChildrenItems(shareUserIds: Uuid[], folderId: string, includeResources: boolean = true): Promise<Item[]> {
if (!shareUserIds.length) throw new Error('User IDs must be specified');

View File

@@ -179,7 +179,7 @@ export default class ShareModel extends BaseModel<Share> {
const addUserItem = async (shareUserId: Uuid, itemId: Uuid) => {
try {
await this.models().userItem().add(shareUserId, itemId);
await this.models().userItem().add(shareUserId, itemId, { queryContext: { uniqueConstraintErrorLoggingDisabled: true } });
} catch (error) {
if (!isUniqueConstraintError(error)) throw error;
}
@@ -322,7 +322,7 @@ export default class ShareModel extends BaseModel<Share> {
for (const resourceItem of resourceItems) {
if (doShare) {
try {
await this.models().userItem().add(toUserId, resourceItem.id);
await this.models().userItem().add(toUserId, resourceItem.id, { queryContext: { uniqueConstraintErrorLoggingDisabled: true } });
} catch (error) {
if (isUniqueConstraintError(error)) {
continue;
@@ -337,7 +337,7 @@ export default class ShareModel extends BaseModel<Share> {
for (const resourceBlobItem of resourceBlobItems) {
if (doShare) {
try {
await this.models().userItem().add(toUserId, resourceBlobItem.id);
await this.models().userItem().add(toUserId, resourceBlobItem.id, { queryContext: { uniqueConstraintErrorLoggingDisabled: true } });
} catch (error) {
if (isUniqueConstraintError(error)) {
continue;

View File

@@ -37,7 +37,7 @@ export default class UserFlagModels extends BaseModel<UserFlag> {
await this.save({
user_id: userId,
type,
});
}, { queryContext: { uniqueConstraintErrorLoggingDisabled: true } });
} catch (error) {
if (!isUniqueConstraintError(error)) {
throw error;

View File

@@ -1,6 +1,7 @@
import Logger from '@joplin/lib/Logger';
import BaseService from './BaseService';
import Mail = require('nodemailer/lib/mailer');
import SMTPTransport = require('nodemailer/lib/smtp-transport');
import { createTransport } from 'nodemailer';
import { Email, EmailSender } from '../services/database/types';
import { errorToString } from '../utils/errors';
@@ -24,16 +25,18 @@ export default class EmailService extends BaseService {
if (!this.senderInfo(EmailSender.NoReply).email) {
throw new Error('No-reply email must be set for email service to work (Set env variable MAILER_NOREPLY_EMAIL)');
}
this.transport_ = createTransport({
const options: SMTPTransport.Options = {
host: this.config.mailer.host,
port: this.config.mailer.port,
secure: this.config.mailer.secure,
auth: {
};
if (this.config.mailer.authUser || this.config.mailer.authPassword) {
options.auth = {
user: this.config.mailer.authUser,
pass: this.config.mailer.authPassword,
},
});
};
}
this.transport_ = createTransport(options);
await this.transport_.verify();
logger.info('Transporter is operational - service will be enabled');

View File

@@ -13,6 +13,7 @@ export enum TaskId {
HandleBetaUserEmails = 4,
HandleFailedPaymentSubscriptions = 5,
DeleteExpiredSessions = 6,
CompressOldChanges = 7,
}
export enum RunType {

View File

@@ -1,7 +1,7 @@
import yargs = require('yargs');
import BaseCommand from '../commands/BaseCommand';
import DbCommand from '../commands/DbCommand';
import DeleteOldChangesCommand from '../commands/DeleteOldChangesCommand';
import CompressOldChangesCommand from '../commands/CompressOldChangesCommand';
import StorageCommand from '../commands/StorageCommand';
import MigrateCommand from '../commands/MigrateCommand';
@@ -16,7 +16,7 @@ export default async function setupCommands(): Promise<Commands> {
const commands: BaseCommand[] = [
new MigrateCommand(),
new DbCommand(),
new DeleteOldChangesCommand(),
new CompressOldChangesCommand(),
new StorageCommand(),
];

View File

@@ -12,6 +12,7 @@ export default function(env: Env, models: Models, config: Config): TaskService {
schedule: '0 */6 * * *',
run: (models: Models) => models.token().deleteExpiredTokens(),
},
{
id: TaskId.UpdateTotalSizes,
description: 'Update total sizes',
@@ -19,6 +20,13 @@ export default function(env: Env, models: Models, config: Config): TaskService {
run: (models: Models) => models.item().updateTotalSizes(),
},
{
id: TaskId.CompressOldChanges,
description: 'Compress old changes',
schedule: '0 0 */2 * *',
run: (models: Models) => models.change().compressOldChanges(),
},
// Need to do it relatively frequently so that if the user fixes
// whatever was causing the oversized account, they can get it
// re-enabled quickly. Also it's done on minute 30 because it depends on

View File

@@ -78,22 +78,24 @@ export async function beforeAllDb(unitName: string, createDbOptions: CreateDbOpt
//
// sudo docker compose -f docker-compose.db-dev.yml up
// await initConfig(Env.Dev, parseEnv({
// DB_CLIENT: 'pg',
// POSTGRES_DATABASE: unitName,
// POSTGRES_USER: 'joplin',
// POSTGRES_PASSWORD: 'joplin',
// SUPPORT_EMAIL: 'testing@localhost',
// }), {
// tempDir: tempDir,
// });
await initConfig(Env.Dev, parseEnv({
SQLITE_DATABASE: createdDbPath_,
SUPPORT_EMAIL: 'testing@localhost',
}), {
tempDir: tempDir,
});
if (process.env.JOPLIN_TESTS_SERVER_DB === 'pg') {
await initConfig(Env.Dev, parseEnv({
DB_CLIENT: 'pg',
POSTGRES_DATABASE: unitName,
POSTGRES_USER: 'joplin',
POSTGRES_PASSWORD: 'joplin',
SUPPORT_EMAIL: 'testing@localhost',
}), {
tempDir: tempDir,
});
} else {
await initConfig(Env.Dev, parseEnv({
SQLITE_DATABASE: createdDbPath_,
SUPPORT_EMAIL: 'testing@localhost',
}), {
tempDir: tempDir,
});
}
initGlobalLogger();

View File

@@ -118,8 +118,12 @@ async function main(argv: any) {
if (winPortableUrl) content = content.replace(/(https:\/\/github.com\/laurent22\/joplin\/releases\/download\/v\d+\.\d+\.\d+\/JoplinPortable.exe)/, winPortableUrl);
if (macOsUrl) content = content.replace(/(https:\/\/github.com\/laurent22\/joplin\/releases\/download\/v\d+\.\d+\.\d+\/Joplin-.*?\.dmg)/, macOsUrl);
if (linuxUrl) content = content.replace(/(https:\/\/github.com\/laurent22\/joplin\/releases\/download\/v\d+\.\d+\.\d+\/Joplin-.*?\.AppImage)/, linuxUrl);
if (androidUrl) content = content.replace(/(https:\/\/github.com\/laurent22\/joplin-android\/releases\/download\/android-v\d+\.\d+\.\d+\/joplin-v\d+\.\d+\.\d+\.apk)/, androidUrl);
if (android32Url) content = content.replace(/(https:\/\/github.com\/laurent22\/joplin-android\/releases\/download\/android-v\d+\.\d+\.\d+\/joplin-v\d+\.\d+\.\d+-32bit\.apk)/, android32Url);
// Disable for now due to broken /latest API end point, which returns a
// version from 6 months ago.
// if (androidUrl) content = content.replace(/(https:\/\/github.com\/laurent22\/joplin-android\/releases\/download\/android-v\d+\.\d+\.\d+\/joplin-v\d+\.\d+\.\d+\.apk)/, androidUrl);
// if (android32Url) content = content.replace(/(https:\/\/github.com\/laurent22\/joplin-android\/releases\/download\/android-v\d+\.\d+\.\d+\/joplin-v\d+\.\d+\.\d+-32bit\.apk)/, android32Url);
setReadmeContent(content);

View File

@@ -1,5 +1,25 @@
# Joplin Server Changelog
## [server-v2.6.13](https://github.com/laurent22/joplin/releases/tag/server-v2.6.13) - 2021-11-29T18:41:28Z
- New: Added command to delete database item content (01048f5)
- Improved: Allow disabling NTP time drift check (dc67eac)
- Improved: Do not set the SMTP auth option when user or password are not set (#5791 by [@MovingEarth](https://github.com/MovingEarth))
- Improved: Increase default MAX_TIME_DRIFT to 100ms (8e54a65)
- Improved: Optimise reading from external storage when fallback driver is not present (4fecb08)
- Improved: Remove unique constraint errors from the log when they are already handled by the application (a6884a2)
- Improved: Retry NTP request up to three times when it fails (7eb1d89)
## [server-v2.6.12](https://github.com/laurent22/joplin/releases/tag/server-v2.6.12) - 2021-11-23T16:30:27Z
- New: Added task to compress changes older than 6 months (75f7296)
- Improved: Allow specifying a max content size when importing content to new storage (080c3cc)
- Improved: Check for time drift when the server starts (#5738)
- Improved: Display more debug info in error log (3716972)
- Improved: Display more detailed error messages on SQL query errors (42a4edb)
- Improved: Perform storage checks before starting services (16d5047)
- Fixed: Fixed HandleOversizedAccounts task interval (fc419d9)
## [server-v2.6.11](https://github.com/laurent22/joplin/releases/tag/server-v2.6.11) - 2021-11-14T17:14:51Z
- Improved: Prevent large data blobs from crashing the application (5eb3a92)

View File

@@ -0,0 +1 @@
This page is used by default on the Joplin iOS app to perform the WiFi connection check. More info at https://joplinapp.org/privacy/

View File

@@ -54,6 +54,14 @@ Difficulty Level: High
Skills Required: TypeScript, JavaScript, CSS, HTML, Markdown rendering.
## 4. Implement default plugins on desktop application
We would like to bundle certain plugins with the desktop application, such as the Backup or Rich Markdown plugin. Some process needs to be implemented so that they are bundled and updated automatically. You'll have to consider how it will work on CI, and across platform. The process should be fault tolerant and retry when something fails.
Difficulty Level: High
Skills Required: TypeScript, JavaScript, knowledge of Electron and GitHub Actions.
# More info
- Make sure you read the [Joplin Google Summer of Code Introduction](https://joplinapp.org/gsoc2022/index/)

View File

@@ -2,9 +2,9 @@
| Name | Value |
| ----- | ----- |
| Total Windows downloads | 1,918,135 |
| Total macOs downloads | 756,609 |
| Total Linux downloads | 614,972 |
| Total Windows downloads | 1,933,436 |
| Total macOs downloads | 761,657 |
| Total Linux downloads | 620,271 |
| Windows % | 58% |
| macOS % | 23% |
| Linux % | 19% |
@@ -17,152 +17,152 @@
| Version | Date | Windows | macOS | Linux | Total |
| ----- | ----- | ----- | ----- | ----- | ----- |
| [v2.6.2](https://github.com/laurent22/joplin/releases/tag/v2.6.2) (p) | 2021-11-18T12:19:12Z | 440 | 158 | 83 | 681 |
| [v2.5.12](https://github.com/laurent22/joplin/releases/tag/v2.5.12) | 2021-11-08T11:07:11Z | 31,146 | 15,552 | 6,004 | 52,702 |
| [v2.5.10](https://github.com/laurent22/joplin/releases/tag/v2.5.10) | 2021-11-01T08:22:42Z | 43,128 | 18,875 | 9,919 | 71,922 |
| [v2.5.8](https://github.com/laurent22/joplin/releases/tag/v2.5.8) | 2021-10-31T11:38:03Z | 12,456 | 6,505 | 2,262 | 21,223 |
| [v2.5.7](https://github.com/laurent22/joplin/releases/tag/v2.5.7) (p) | 2021-10-29T14:47:33Z | 566 | 181 | 124 | 871 |
| [v2.5.6](https://github.com/laurent22/joplin/releases/tag/v2.5.6) (p) | 2021-10-28T22:03:09Z | 509 | 150 | 80 | 739 |
| [v2.5.4](https://github.com/laurent22/joplin/releases/tag/v2.5.4) (p) | 2021-10-19T10:10:54Z | 1,716 | 546 | 540 | 2,802 |
| [v2.4.12](https://github.com/laurent22/joplin/releases/tag/v2.4.12) | 2021-10-13T17:24:34Z | 42,768 | 19,885 | 9,703 | 72,356 |
| [v2.5.1](https://github.com/laurent22/joplin/releases/tag/v2.5.1) (p) | 2021-10-02T09:51:58Z | 2,868 | 873 | 916 | 4,657 |
| [v2.4.9](https://github.com/laurent22/joplin/releases/tag/v2.4.9) | 2021-09-29T19:08:58Z | 55,343 | 23,142 | 15,754 | 94,239 |
| [v2.4.8](https://github.com/laurent22/joplin/releases/tag/v2.4.8) (p) | 2021-09-22T19:01:46Z | 6,868 | 1,747 | 503 | 9,118 |
| [v2.4.7](https://github.com/laurent22/joplin/releases/tag/v2.4.7) (p) | 2021-09-19T12:53:22Z | 971 | 231 | 179 | 1,381 |
| [v2.4.6](https://github.com/laurent22/joplin/releases/tag/v2.4.6) (p) | 2021-09-09T18:57:17Z | 1,554 | 437 | 490 | 2,481 |
| [v2.4.5](https://github.com/laurent22/joplin/releases/tag/v2.4.5) (p) | 2021-09-06T18:03:28Z | 1,033 | 247 | 203 | 1,483 |
| [v2.4.4](https://github.com/laurent22/joplin/releases/tag/v2.4.4) (p) | 2021-08-30T16:02:51Z | 1,308 | 356 | 336 | 2,000 |
| [v2.4.3](https://github.com/laurent22/joplin/releases/tag/v2.4.3) (p) | 2021-08-28T15:27:32Z | 829 | 180 | 144 | 1,153 |
| [v2.4.2](https://github.com/laurent22/joplin/releases/tag/v2.4.2) (p) | 2021-08-27T17:13:21Z | 528 | 124 | 70 | 722 |
| [v2.4.1](https://github.com/laurent22/joplin/releases/tag/v2.4.1) (p) | 2021-08-21T11:52:30Z | 1,421 | 351 | 314 | 2,086 |
| [v2.3.5](https://github.com/laurent22/joplin/releases/tag/v2.3.5) | 2021-08-17T06:43:30Z | 80,574 | 31,312 | 32,999 | 144,885 |
| [v2.3.3](https://github.com/laurent22/joplin/releases/tag/v2.3.3) | 2021-08-14T09:19:40Z | 14,128 | 6,846 | 4,024 | 24,998 |
| [v2.2.7](https://github.com/laurent22/joplin/releases/tag/v2.2.7) | 2021-08-11T11:03:26Z | 14,608 | 7,469 | 2,556 | 24,633 |
| [v2.2.6](https://github.com/laurent22/joplin/releases/tag/v2.2.6) (p) | 2021-08-09T19:29:20Z | 7,411 | 4,598 | 936 | 12,945 |
| [v2.2.5](https://github.com/laurent22/joplin/releases/tag/v2.2.5) (p) | 2021-08-07T10:35:24Z | 1,128 | 255 | 185 | 1,568 |
| [v2.2.4](https://github.com/laurent22/joplin/releases/tag/v2.2.4) (p) | 2021-08-05T16:42:48Z | 835 | 185 | 111 | 1,131 |
| [v2.2.2](https://github.com/laurent22/joplin/releases/tag/v2.2.2) (p) | 2021-07-19T10:28:35Z | 2,746 | 715 | 625 | 4,086 |
| [v2.1.9](https://github.com/laurent22/joplin/releases/tag/v2.1.9) | 2021-07-19T10:28:43Z | 45,636 | 18,751 | 16,674 | 81,061 |
| [v2.2.1](https://github.com/laurent22/joplin/releases/tag/v2.2.1) (p) | 2021-07-09T17:38:25Z | 2,097 | 395 | 371 | 2,863 |
| [v2.1.8](https://github.com/laurent22/joplin/releases/tag/v2.1.8) | 2021-07-03T08:25:16Z | 29,447 | 12,145 | 12,673 | 54,265 |
| [v2.1.7](https://github.com/laurent22/joplin/releases/tag/v2.1.7) | 2021-06-26T19:48:55Z | 13,517 | 6,375 | 3,599 | 23,491 |
| [v2.1.5](https://github.com/laurent22/joplin/releases/tag/v2.1.5) (p) | 2021-06-23T15:08:52Z | 1,170 | 224 | 175 | 1,569 |
| [v2.1.3](https://github.com/laurent22/joplin/releases/tag/v2.1.3) (p) | 2021-06-19T16:32:51Z | 1,314 | 288 | 193 | 1,795 |
| [v2.0.11](https://github.com/laurent22/joplin/releases/tag/v2.0.11) | 2021-06-16T17:55:49Z | 22,742 | 9,211 | 9,771 | 41,724 |
| [v2.0.10](https://github.com/laurent22/joplin/releases/tag/v2.0.10) | 2021-06-16T07:58:29Z | 2,151 | 910 | 361 | 3,422 |
| [v2.0.9](https://github.com/laurent22/joplin/releases/tag/v2.0.9) (p) | 2021-06-12T09:30:30Z | 1,220 | 286 | 871 | 2,377 |
| [v2.0.8](https://github.com/laurent22/joplin/releases/tag/v2.0.8) (p) | 2021-06-10T16:15:08Z | 855 | 219 | 567 | 1,641 |
| [v2.0.4](https://github.com/laurent22/joplin/releases/tag/v2.0.4) (p) | 2021-06-02T12:54:17Z | 1,384 | 384 | 370 | 2,138 |
| [v2.0.2](https://github.com/laurent22/joplin/releases/tag/v2.0.2) (p) | 2021-05-21T18:07:48Z | 2,379 | 485 | 1,659 | 4,523 |
| [v2.6.2](https://github.com/laurent22/joplin/releases/tag/v2.6.2) (p) | 2021-11-18T12:19:12Z | 1,352 | 430 | 301 | 2,083 |
| [v2.5.12](https://github.com/laurent22/joplin/releases/tag/v2.5.12) | 2021-11-08T11:07:11Z | 44,204 | 20,249 | 11,017 | 75,470 |
| [v2.5.10](https://github.com/laurent22/joplin/releases/tag/v2.5.10) | 2021-11-01T08:22:42Z | 43,257 | 18,896 | 9,936 | 72,089 |
| [v2.5.8](https://github.com/laurent22/joplin/releases/tag/v2.5.8) | 2021-10-31T11:38:03Z | 12,495 | 6,507 | 2,264 | 21,266 |
| [v2.5.7](https://github.com/laurent22/joplin/releases/tag/v2.5.7) (p) | 2021-10-29T14:47:33Z | 601 | 181 | 124 | 906 |
| [v2.5.6](https://github.com/laurent22/joplin/releases/tag/v2.5.6) (p) | 2021-10-28T22:03:09Z | 545 | 150 | 80 | 775 |
| [v2.5.4](https://github.com/laurent22/joplin/releases/tag/v2.5.4) (p) | 2021-10-19T10:10:54Z | 1,756 | 547 | 542 | 2,845 |
| [v2.4.12](https://github.com/laurent22/joplin/releases/tag/v2.4.12) | 2021-10-13T17:24:34Z | 42,818 | 19,892 | 9,712 | 72,422 |
| [v2.5.1](https://github.com/laurent22/joplin/releases/tag/v2.5.1) (p) | 2021-10-02T09:51:58Z | 2,905 | 874 | 917 | 4,696 |
| [v2.4.9](https://github.com/laurent22/joplin/releases/tag/v2.4.9) | 2021-09-29T19:08:58Z | 55,401 | 23,144 | 15,755 | 94,300 |
| [v2.4.8](https://github.com/laurent22/joplin/releases/tag/v2.4.8) (p) | 2021-09-22T19:01:46Z | 6,881 | 1,747 | 503 | 9,131 |
| [v2.4.7](https://github.com/laurent22/joplin/releases/tag/v2.4.7) (p) | 2021-09-19T12:53:22Z | 980 | 231 | 181 | 1,392 |
| [v2.4.6](https://github.com/laurent22/joplin/releases/tag/v2.4.6) (p) | 2021-09-09T18:57:17Z | 1,569 | 438 | 490 | 2,497 |
| [v2.4.5](https://github.com/laurent22/joplin/releases/tag/v2.4.5) (p) | 2021-09-06T18:03:28Z | 1,046 | 247 | 203 | 1,496 |
| [v2.4.4](https://github.com/laurent22/joplin/releases/tag/v2.4.4) (p) | 2021-08-30T16:02:51Z | 1,317 | 356 | 336 | 2,009 |
| [v2.4.3](https://github.com/laurent22/joplin/releases/tag/v2.4.3) (p) | 2021-08-28T15:27:32Z | 843 | 180 | 144 | 1,167 |
| [v2.4.2](https://github.com/laurent22/joplin/releases/tag/v2.4.2) (p) | 2021-08-27T17:13:21Z | 537 | 124 | 70 | 731 |
| [v2.4.1](https://github.com/laurent22/joplin/releases/tag/v2.4.1) (p) | 2021-08-21T11:52:30Z | 1,430 | 351 | 314 | 2,095 |
| [v2.3.5](https://github.com/laurent22/joplin/releases/tag/v2.3.5) | 2021-08-17T06:43:30Z | 80,602 | 31,316 | 33,004 | 144,922 |
| [v2.3.3](https://github.com/laurent22/joplin/releases/tag/v2.3.3) | 2021-08-14T09:19:40Z | 14,145 | 6,846 | 4,024 | 25,015 |
| [v2.2.7](https://github.com/laurent22/joplin/releases/tag/v2.2.7) | 2021-08-11T11:03:26Z | 14,621 | 7,469 | 2,557 | 24,647 |
| [v2.2.6](https://github.com/laurent22/joplin/releases/tag/v2.2.6) (p) | 2021-08-09T19:29:20Z | 7,424 | 4,598 | 936 | 12,958 |
| [v2.2.5](https://github.com/laurent22/joplin/releases/tag/v2.2.5) (p) | 2021-08-07T10:35:24Z | 1,137 | 255 | 185 | 1,577 |
| [v2.2.4](https://github.com/laurent22/joplin/releases/tag/v2.2.4) (p) | 2021-08-05T16:42:48Z | 844 | 185 | 111 | 1,140 |
| [v2.2.2](https://github.com/laurent22/joplin/releases/tag/v2.2.2) (p) | 2021-07-19T10:28:35Z | 2,755 | 715 | 625 | 4,095 |
| [v2.1.9](https://github.com/laurent22/joplin/releases/tag/v2.1.9) | 2021-07-19T10:28:43Z | 45,653 | 18,751 | 16,674 | 81,078 |
| [v2.2.1](https://github.com/laurent22/joplin/releases/tag/v2.2.1) (p) | 2021-07-09T17:38:25Z | 2,109 | 395 | 371 | 2,875 |
| [v2.1.8](https://github.com/laurent22/joplin/releases/tag/v2.1.8) | 2021-07-03T08:25:16Z | 29,468 | 12,146 | 12,680 | 54,294 |
| [v2.1.7](https://github.com/laurent22/joplin/releases/tag/v2.1.7) | 2021-06-26T19:48:55Z | 13,528 | 6,375 | 3,599 | 23,502 |
| [v2.1.5](https://github.com/laurent22/joplin/releases/tag/v2.1.5) (p) | 2021-06-23T15:08:52Z | 1,179 | 224 | 175 | 1,578 |
| [v2.1.3](https://github.com/laurent22/joplin/releases/tag/v2.1.3) (p) | 2021-06-19T16:32:51Z | 1,323 | 288 | 193 | 1,804 |
| [v2.0.11](https://github.com/laurent22/joplin/releases/tag/v2.0.11) | 2021-06-16T17:55:49Z | 22,761 | 9,211 | 9,773 | 41,745 |
| [v2.0.10](https://github.com/laurent22/joplin/releases/tag/v2.0.10) | 2021-06-16T07:58:29Z | 2,160 | 910 | 361 | 3,431 |
| [v2.0.9](https://github.com/laurent22/joplin/releases/tag/v2.0.9) (p) | 2021-06-12T09:30:30Z | 1,230 | 286 | 871 | 2,387 |
| [v2.0.8](https://github.com/laurent22/joplin/releases/tag/v2.0.8) (p) | 2021-06-10T16:15:08Z | 864 | 219 | 567 | 1,650 |
| [v2.0.4](https://github.com/laurent22/joplin/releases/tag/v2.0.4) (p) | 2021-06-02T12:54:17Z | 1,389 | 384 | 370 | 2,143 |
| [v2.0.2](https://github.com/laurent22/joplin/releases/tag/v2.0.2) (p) | 2021-05-21T18:07:48Z | 2,388 | 485 | 1,659 | 4,532 |
| [v2.0.1](https://github.com/laurent22/joplin/releases/tag/v2.0.1) (p) | 2021-05-15T13:22:58Z | 853 | 265 | 1,016 | 2,134 |
| [v1.8.5](https://github.com/laurent22/joplin/releases/tag/v1.8.5) | 2021-05-10T11:58:14Z | 37,433 | 16,232 | 19,372 | 73,037 |
| [v1.8.4](https://github.com/laurent22/joplin/releases/tag/v1.8.4) (p) | 2021-05-09T18:05:05Z | 901 | 131 | 450 | 1,482 |
| [v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (p) | 2021-05-04T10:38:16Z | 1,667 | 301 | 932 | 2,900 |
| [v1.8.2](https://github.com/laurent22/joplin/releases/tag/v1.8.2) (p) | 2021-04-25T10:50:51Z | 1,907 | 432 | 1,279 | 3,618 |
| [v1.8.1](https://github.com/laurent22/joplin/releases/tag/v1.8.1) (p) | 2021-03-29T10:46:41Z | 3,283 | 821 | 2,443 | 6,547 |
| [v1.7.11](https://github.com/laurent22/joplin/releases/tag/v1.7.11) | 2021-02-03T12:50:01Z | 115,330 | 42,715 | 64,225 | 222,270 |
| [v1.7.10](https://github.com/laurent22/joplin/releases/tag/v1.7.10) | 2021-01-30T13:25:29Z | 13,932 | 4,848 | 4,458 | 23,238 |
| [v1.8.5](https://github.com/laurent22/joplin/releases/tag/v1.8.5) | 2021-05-10T11:58:14Z | 37,453 | 16,234 | 19,372 | 73,059 |
| [v1.8.4](https://github.com/laurent22/joplin/releases/tag/v1.8.4) (p) | 2021-05-09T18:05:05Z | 910 | 131 | 450 | 1,491 |
| [v1.8.3](https://github.com/laurent22/joplin/releases/tag/v1.8.3) (p) | 2021-05-04T10:38:16Z | 1,681 | 301 | 932 | 2,914 |
| [v1.8.2](https://github.com/laurent22/joplin/releases/tag/v1.8.2) (p) | 2021-04-25T10:50:51Z | 1,924 | 432 | 1,279 | 3,635 |
| [v1.8.1](https://github.com/laurent22/joplin/releases/tag/v1.8.1) (p) | 2021-03-29T10:46:41Z | 3,292 | 821 | 2,443 | 6,556 |
| [v1.7.11](https://github.com/laurent22/joplin/releases/tag/v1.7.11) | 2021-02-03T12:50:01Z | 115,375 | 42,718 | 64,226 | 222,319 |
| [v1.7.10](https://github.com/laurent22/joplin/releases/tag/v1.7.10) | 2021-01-30T13:25:29Z | 13,935 | 4,848 | 4,459 | 23,242 |
| [v1.7.9](https://github.com/laurent22/joplin/releases/tag/v1.7.9) (p) | 2021-01-28T09:50:21Z | 502 | 132 | 497 | 1,131 |
| [v1.7.6](https://github.com/laurent22/joplin/releases/tag/v1.7.6) (p) | 2021-01-27T10:36:05Z | 310 | 92 | 286 | 688 |
| [v1.7.5](https://github.com/laurent22/joplin/releases/tag/v1.7.5) (p) | 2021-01-26T09:53:05Z | 386 | 203 | 452 | 1,041 |
| [v1.7.4](https://github.com/laurent22/joplin/releases/tag/v1.7.4) (p) | 2021-01-22T17:58:38Z | 691 | 203 | 623 | 1,517 |
| [v1.6.8](https://github.com/laurent22/joplin/releases/tag/v1.6.8) | 2021-01-20T18:11:34Z | 18,895 | 7,684 | 7,595 | 34,174 |
| [v1.6.8](https://github.com/laurent22/joplin/releases/tag/v1.6.8) | 2021-01-20T18:11:34Z | 18,923 | 7,684 | 7,595 | 34,202 |
| [v1.7.3](https://github.com/laurent22/joplin/releases/tag/v1.7.3) (p) | 2021-01-20T11:23:50Z | 345 | 76 | 442 | 863 |
| [v1.6.7](https://github.com/laurent22/joplin/releases/tag/v1.6.7) | 2021-01-11T23:20:33Z | 10,948 | 4,631 | 4,541 | 20,120 |
| [v1.6.6](https://github.com/laurent22/joplin/releases/tag/v1.6.6) | 2021-01-09T16:15:31Z | 12,478 | 3,416 | 4,793 | 20,687 |
| [v1.6.5](https://github.com/laurent22/joplin/releases/tag/v1.6.5) (p) | 2021-01-09T01:24:32Z | 991 | 72 | 307 | 1,370 |
| [v1.6.7](https://github.com/laurent22/joplin/releases/tag/v1.6.7) | 2021-01-11T23:20:33Z | 10,974 | 4,631 | 4,541 | 20,146 |
| [v1.6.6](https://github.com/laurent22/joplin/releases/tag/v1.6.6) | 2021-01-09T16:15:31Z | 12,482 | 3,416 | 4,793 | 20,691 |
| [v1.6.5](https://github.com/laurent22/joplin/releases/tag/v1.6.5) (p) | 2021-01-09T01:24:32Z | 1,008 | 72 | 307 | 1,387 |
| [v1.6.4](https://github.com/laurent22/joplin/releases/tag/v1.6.4) (p) | 2021-01-07T19:11:32Z | 389 | 78 | 201 | 668 |
| [v1.6.2](https://github.com/laurent22/joplin/releases/tag/v1.6.2) (p) | 2021-01-04T22:34:55Z | 671 | 228 | 589 | 1,488 |
| [v1.5.14](https://github.com/laurent22/joplin/releases/tag/v1.5.14) | 2020-12-30T01:48:46Z | 11,420 | 5,202 | 5,524 | 22,146 |
| [v1.5.14](https://github.com/laurent22/joplin/releases/tag/v1.5.14) | 2020-12-30T01:48:46Z | 11,444 | 5,202 | 5,524 | 22,170 |
| [v1.6.1](https://github.com/laurent22/joplin/releases/tag/v1.6.1) (p) | 2020-12-29T19:37:45Z | 169 | 36 | 166 | 371 |
| [v1.5.13](https://github.com/laurent22/joplin/releases/tag/v1.5.13) | 2020-12-29T18:29:15Z | 625 | 217 | 199 | 1,041 |
| [v1.5.12](https://github.com/laurent22/joplin/releases/tag/v1.5.12) | 2020-12-28T15:14:08Z | 2,405 | 1,767 | 921 | 5,093 |
| [v1.5.11](https://github.com/laurent22/joplin/releases/tag/v1.5.11) | 2020-12-27T19:54:07Z | 14,121 | 4,623 | 4,271 | 23,015 |
| [v1.5.11](https://github.com/laurent22/joplin/releases/tag/v1.5.11) | 2020-12-27T19:54:07Z | 14,123 | 4,623 | 4,271 | 23,017 |
| [v1.5.10](https://github.com/laurent22/joplin/releases/tag/v1.5.10) (p) | 2020-12-26T12:35:36Z | 294 | 106 | 266 | 666 |
| [v1.5.9](https://github.com/laurent22/joplin/releases/tag/v1.5.9) (p) | 2020-12-23T18:01:08Z | 327 | 371 | 408 | 1,106 |
| [v1.5.8](https://github.com/laurent22/joplin/releases/tag/v1.5.8) (p) | 2020-12-20T09:45:19Z | 566 | 164 | 640 | 1,370 |
| [v1.5.7](https://github.com/laurent22/joplin/releases/tag/v1.5.7) (p) | 2020-12-10T12:58:33Z | 889 | 252 | 991 | 2,132 |
| [v1.5.4](https://github.com/laurent22/joplin/releases/tag/v1.5.4) (p) | 2020-12-05T12:07:49Z | 692 | 165 | 632 | 1,489 |
| [v1.4.19](https://github.com/laurent22/joplin/releases/tag/v1.4.19) | 2020-12-01T11:11:16Z | 26,147 | 13,418 | 11,657 | 51,222 |
| [v1.4.18](https://github.com/laurent22/joplin/releases/tag/v1.4.18) | 2020-11-28T12:21:41Z | 11,255 | 3,878 | 3,128 | 18,261 |
| [v1.4.19](https://github.com/laurent22/joplin/releases/tag/v1.4.19) | 2020-12-01T11:11:16Z | 26,169 | 13,418 | 11,657 | 51,244 |
| [v1.4.18](https://github.com/laurent22/joplin/releases/tag/v1.4.18) | 2020-11-28T12:21:41Z | 11,262 | 3,878 | 3,130 | 18,270 |
| [v1.4.16](https://github.com/laurent22/joplin/releases/tag/v1.4.16) | 2020-11-27T19:40:16Z | 1,475 | 827 | 594 | 2,896 |
| [v1.4.15](https://github.com/laurent22/joplin/releases/tag/v1.4.15) | 2020-11-27T13:25:43Z | 892 | 486 | 273 | 1,651 |
| [v1.4.12](https://github.com/laurent22/joplin/releases/tag/v1.4.12) | 2020-11-23T18:58:07Z | 3,018 | 1,325 | 1,300 | 5,643 |
| [v1.4.11](https://github.com/laurent22/joplin/releases/tag/v1.4.11) (p) | 2020-11-19T23:06:51Z | 1,473 | 157 | 591 | 2,221 |
| [v1.4.10](https://github.com/laurent22/joplin/releases/tag/v1.4.10) (p) | 2020-11-14T09:53:15Z | 627 | 196 | 685 | 1,508 |
| [v1.4.9](https://github.com/laurent22/joplin/releases/tag/v1.4.9) (p) | 2020-11-11T14:23:17Z | 637 | 141 | 403 | 1,181 |
| [v1.4.11](https://github.com/laurent22/joplin/releases/tag/v1.4.11) (p) | 2020-11-19T23:06:51Z | 1,496 | 157 | 591 | 2,244 |
| [v1.4.10](https://github.com/laurent22/joplin/releases/tag/v1.4.10) (p) | 2020-11-14T09:53:15Z | 628 | 196 | 685 | 1,509 |
| [v1.4.9](https://github.com/laurent22/joplin/releases/tag/v1.4.9) (p) | 2020-11-11T14:23:17Z | 642 | 141 | 403 | 1,186 |
| [v1.4.7](https://github.com/laurent22/joplin/releases/tag/v1.4.7) (p) | 2020-11-07T18:23:29Z | 519 | 173 | 515 | 1,207 |
| [v1.3.18](https://github.com/laurent22/joplin/releases/tag/v1.3.18) | 2020-11-06T12:07:02Z | 31,379 | 11,333 | 10,512 | 53,224 |
| [v1.3.18](https://github.com/laurent22/joplin/releases/tag/v1.3.18) | 2020-11-06T12:07:02Z | 31,408 | 11,333 | 10,512 | 53,253 |
| [v1.3.17](https://github.com/laurent22/joplin/releases/tag/v1.3.17) (p) | 2020-11-06T11:35:15Z | 50 | 25 | 24 | 99 |
| [v1.4.6](https://github.com/laurent22/joplin/releases/tag/v1.4.6) (p) | 2020-11-05T22:44:12Z | 465 | 93 | 54 | 612 |
| [v1.3.15](https://github.com/laurent22/joplin/releases/tag/v1.3.15) | 2020-11-04T12:22:50Z | 2,365 | 1,299 | 846 | 4,510 |
| [v1.4.6](https://github.com/laurent22/joplin/releases/tag/v1.4.6) (p) | 2020-11-05T22:44:12Z | 467 | 93 | 54 | 614 |
| [v1.3.15](https://github.com/laurent22/joplin/releases/tag/v1.3.15) | 2020-11-04T12:22:50Z | 2,369 | 1,299 | 846 | 4,514 |
| [v1.3.11](https://github.com/laurent22/joplin/releases/tag/v1.3.11) (p) | 2020-10-31T13:22:20Z | 699 | 186 | 481 | 1,366 |
| [v1.3.10](https://github.com/laurent22/joplin/releases/tag/v1.3.10) (p) | 2020-10-29T13:27:14Z | 376 | 115 | 317 | 808 |
| [v1.3.9](https://github.com/laurent22/joplin/releases/tag/v1.3.9) (p) | 2020-10-23T16:04:26Z | 839 | 241 | 634 | 1,714 |
| [v1.3.10](https://github.com/laurent22/joplin/releases/tag/v1.3.10) (p) | 2020-10-29T13:27:14Z | 377 | 115 | 317 | 809 |
| [v1.3.9](https://github.com/laurent22/joplin/releases/tag/v1.3.9) (p) | 2020-10-23T16:04:26Z | 839 | 242 | 634 | 1,715 |
| [v1.3.8](https://github.com/laurent22/joplin/releases/tag/v1.3.8) (p) | 2020-10-21T18:46:29Z | 519 | 113 | 331 | 963 |
| [v1.3.7](https://github.com/laurent22/joplin/releases/tag/v1.3.7) (p) | 2020-10-20T11:35:55Z | 297 | 85 | 343 | 725 |
| [v1.3.5](https://github.com/laurent22/joplin/releases/tag/v1.3.5) (p) | 2020-10-17T14:26:35Z | 471 | 134 | 407 | 1,012 |
| [v1.3.3](https://github.com/laurent22/joplin/releases/tag/v1.3.3) (p) | 2020-10-17T10:56:57Z | 121 | 47 | 34 | 202 |
| [v1.3.2](https://github.com/laurent22/joplin/releases/tag/v1.3.2) (p) | 2020-10-11T20:39:49Z | 667 | 180 | 566 | 1,413 |
| [v1.3.1](https://github.com/laurent22/joplin/releases/tag/v1.3.1) (p) | 2020-10-11T15:10:18Z | 85 | 52 | 44 | 181 |
| [v1.2.6](https://github.com/laurent22/joplin/releases/tag/v1.2.6) | 2020-10-09T13:56:59Z | 44,959 | 17,732 | 14,042 | 76,733 |
| [v1.2.4](https://github.com/laurent22/joplin/releases/tag/v1.2.4) (p) | 2020-09-30T07:34:29Z | 816 | 247 | 799 | 1,862 |
| [v1.2.6](https://github.com/laurent22/joplin/releases/tag/v1.2.6) | 2020-10-09T13:56:59Z | 44,985 | 17,733 | 14,042 | 76,760 |
| [v1.2.4](https://github.com/laurent22/joplin/releases/tag/v1.2.4) (p) | 2020-09-30T07:34:29Z | 816 | 249 | 799 | 1,864 |
| [v1.2.3](https://github.com/laurent22/joplin/releases/tag/v1.2.3) (p) | 2020-09-29T15:13:02Z | 220 | 67 | 81 | 368 |
| [v1.2.2](https://github.com/laurent22/joplin/releases/tag/v1.2.2) (p) | 2020-09-22T20:31:55Z | 908 | 206 | 639 | 1,753 |
| [v1.1.4](https://github.com/laurent22/joplin/releases/tag/v1.1.4) | 2020-09-21T11:20:09Z | 27,767 | 13,503 | 7,753 | 49,023 |
| [v1.2.2](https://github.com/laurent22/joplin/releases/tag/v1.2.2) (p) | 2020-09-22T20:31:55Z | 913 | 208 | 639 | 1,760 |
| [v1.1.4](https://github.com/laurent22/joplin/releases/tag/v1.1.4) | 2020-09-21T11:20:09Z | 27,770 | 13,505 | 7,753 | 49,028 |
| [v1.1.3](https://github.com/laurent22/joplin/releases/tag/v1.1.3) (p) | 2020-09-17T10:30:37Z | 566 | 154 | 466 | 1,186 |
| [v1.1.2](https://github.com/laurent22/joplin/releases/tag/v1.1.2) (p) | 2020-09-15T12:58:38Z | 380 | 120 | 253 | 753 |
| [v1.1.1](https://github.com/laurent22/joplin/releases/tag/v1.1.1) (p) | 2020-09-11T23:32:47Z | 535 | 201 | 352 | 1,088 |
| [v1.0.245](https://github.com/laurent22/joplin/releases/tag/v1.0.245) | 2020-09-09T12:56:10Z | 21,528 | 10,009 | 5,642 | 37,179 |
| [v1.0.242](https://github.com/laurent22/joplin/releases/tag/v1.0.242) | 2020-09-04T22:00:34Z | 12,599 | 6,425 | 3,019 | 22,043 |
| [v1.0.241](https://github.com/laurent22/joplin/releases/tag/v1.0.241) | 2020-09-04T18:06:00Z | 24,463 | 5,804 | 5,054 | 35,321 |
| [v1.0.239](https://github.com/laurent22/joplin/releases/tag/v1.0.239) (p) | 2020-09-01T21:56:36Z | 725 | 232 | 405 | 1,362 |
| [v1.0.245](https://github.com/laurent22/joplin/releases/tag/v1.0.245) | 2020-09-09T12:56:10Z | 21,542 | 10,010 | 5,642 | 37,194 |
| [v1.0.242](https://github.com/laurent22/joplin/releases/tag/v1.0.242) | 2020-09-04T22:00:34Z | 12,603 | 6,425 | 3,019 | 22,047 |
| [v1.0.241](https://github.com/laurent22/joplin/releases/tag/v1.0.241) | 2020-09-04T18:06:00Z | 24,520 | 5,809 | 5,054 | 35,383 |
| [v1.0.239](https://github.com/laurent22/joplin/releases/tag/v1.0.239) (p) | 2020-09-01T21:56:36Z | 728 | 232 | 405 | 1,365 |
| [v1.0.237](https://github.com/laurent22/joplin/releases/tag/v1.0.237) (p) | 2020-08-29T15:38:04Z | 596 | 930 | 342 | 1,868 |
| [v1.0.236](https://github.com/laurent22/joplin/releases/tag/v1.0.236) (p) | 2020-08-28T09:16:54Z | 321 | 117 | 108 | 546 |
| [v1.0.235](https://github.com/laurent22/joplin/releases/tag/v1.0.235) (p) | 2020-08-18T22:08:01Z | 1,796 | 496 | 926 | 3,218 |
| [v1.0.235](https://github.com/laurent22/joplin/releases/tag/v1.0.235) (p) | 2020-08-18T22:08:01Z | 1,801 | 496 | 926 | 3,223 |
| [v1.0.234](https://github.com/laurent22/joplin/releases/tag/v1.0.234) (p) | 2020-08-17T23:13:02Z | 559 | 131 | 104 | 794 |
| [v1.0.233](https://github.com/laurent22/joplin/releases/tag/v1.0.233) | 2020-08-01T14:51:15Z | 43,878 | 18,200 | 12,363 | 74,441 |
| [v1.0.233](https://github.com/laurent22/joplin/releases/tag/v1.0.233) | 2020-08-01T14:51:15Z | 43,903 | 18,200 | 12,363 | 74,466 |
| [v1.0.232](https://github.com/laurent22/joplin/releases/tag/v1.0.232) (p) | 2020-07-28T22:34:40Z | 660 | 229 | 183 | 1,072 |
| [v1.0.227](https://github.com/laurent22/joplin/releases/tag/v1.0.227) | 2020-07-07T20:44:54Z | 40,746 | 15,284 | 9,636 | 65,666 |
| [v1.0.227](https://github.com/laurent22/joplin/releases/tag/v1.0.227) | 2020-07-07T20:44:54Z | 40,754 | 15,284 | 9,636 | 65,674 |
| [v1.0.226](https://github.com/laurent22/joplin/releases/tag/v1.0.226) (p) | 2020-07-04T10:21:26Z | 4,919 | 2,259 | 692 | 7,870 |
| [v1.0.224](https://github.com/laurent22/joplin/releases/tag/v1.0.224) | 2020-06-20T22:26:08Z | 24,808 | 11,013 | 6,011 | 41,832 |
| [v1.0.223](https://github.com/laurent22/joplin/releases/tag/v1.0.223) (p) | 2020-06-20T11:51:27Z | 194 | 118 | 82 | 394 |
| [v1.0.221](https://github.com/laurent22/joplin/releases/tag/v1.0.221) (p) | 2020-06-20T01:44:20Z | 862 | 212 | 214 | 1,288 |
| [v1.0.220](https://github.com/laurent22/joplin/releases/tag/v1.0.220) | 2020-06-13T18:26:22Z | 32,034 | 9,927 | 6,419 | 48,380 |
| [v1.0.220](https://github.com/laurent22/joplin/releases/tag/v1.0.220) | 2020-06-13T18:26:22Z | 32,047 | 9,927 | 6,419 | 48,393 |
| [v1.0.218](https://github.com/laurent22/joplin/releases/tag/v1.0.218) | 2020-06-07T10:43:34Z | 14,549 | 6,975 | 2,960 | 24,484 |
| [v1.0.217](https://github.com/laurent22/joplin/releases/tag/v1.0.217) (p) | 2020-06-06T15:17:27Z | 232 | 99 | 58 | 389 |
| [v1.0.216](https://github.com/laurent22/joplin/releases/tag/v1.0.216) | 2020-05-24T14:21:01Z | 37,838 | 14,284 | 10,186 | 62,308 |
| [v1.0.216](https://github.com/laurent22/joplin/releases/tag/v1.0.216) | 2020-05-24T14:21:01Z | 37,855 | 14,284 | 10,186 | 62,325 |
| [v1.0.214](https://github.com/laurent22/joplin/releases/tag/v1.0.214) (p) | 2020-05-21T17:15:15Z | 6,569 | 3,474 | 766 | 10,809 |
| [v1.0.212](https://github.com/laurent22/joplin/releases/tag/v1.0.212) (p) | 2020-05-21T07:48:39Z | 218 | 74 | 51 | 343 |
| [v1.0.211](https://github.com/laurent22/joplin/releases/tag/v1.0.211) (p) | 2020-05-20T08:59:16Z | 307 | 138 | 91 | 536 |
| [v1.0.209](https://github.com/laurent22/joplin/releases/tag/v1.0.209) (p) | 2020-05-17T18:32:51Z | 1,399 | 858 | 151 | 2,408 |
| [v1.0.207](https://github.com/laurent22/joplin/releases/tag/v1.0.207) (p) | 2020-05-10T16:37:35Z | 1,197 | 270 | 1,020 | 2,487 |
| [v1.0.201](https://github.com/laurent22/joplin/releases/tag/v1.0.201) | 2020-04-15T22:55:13Z | 53,819 | 20,049 | 18,183 | 92,051 |
| [v1.0.201](https://github.com/laurent22/joplin/releases/tag/v1.0.201) | 2020-04-15T22:55:13Z | 53,838 | 20,050 | 18,183 | 92,071 |
| [v1.0.200](https://github.com/laurent22/joplin/releases/tag/v1.0.200) | 2020-04-12T12:17:46Z | 9,563 | 4,896 | 1,907 | 16,366 |
| [v1.0.199](https://github.com/laurent22/joplin/releases/tag/v1.0.199) | 2020-04-10T18:41:58Z | 19,477 | 5,891 | 3,793 | 29,161 |
| [v1.0.197](https://github.com/laurent22/joplin/releases/tag/v1.0.197) | 2020-03-30T17:21:22Z | 22,481 | 9,569 | 5,856 | 37,906 |
| [v1.0.195](https://github.com/laurent22/joplin/releases/tag/v1.0.195) | 2020-03-22T19:56:12Z | 19,007 | 7,953 | 4,509 | 31,469 |
| [v1.0.199](https://github.com/laurent22/joplin/releases/tag/v1.0.199) | 2020-04-10T18:41:58Z | 19,479 | 5,893 | 3,793 | 29,165 |
| [v1.0.197](https://github.com/laurent22/joplin/releases/tag/v1.0.197) | 2020-03-30T17:21:22Z | 22,490 | 9,569 | 5,861 | 37,920 |
| [v1.0.195](https://github.com/laurent22/joplin/releases/tag/v1.0.195) | 2020-03-22T19:56:12Z | 19,010 | 7,953 | 4,509 | 31,472 |
| [v1.0.194](https://github.com/laurent22/joplin/releases/tag/v1.0.194) (p) | 2020-03-14T00:00:32Z | 1,289 | 1,388 | 521 | 3,198 |
| [v1.0.193](https://github.com/laurent22/joplin/releases/tag/v1.0.193) | 2020-03-08T08:58:53Z | 28,670 | 10,911 | 7,401 | 46,982 |
| [v1.0.192](https://github.com/laurent22/joplin/releases/tag/v1.0.192) (p) | 2020-03-06T23:27:52Z | 480 | 126 | 92 | 698 |
| [v1.0.190](https://github.com/laurent22/joplin/releases/tag/v1.0.190) (p) | 2020-03-06T01:22:22Z | 381 | 95 | 88 | 564 |
| [v1.0.189](https://github.com/laurent22/joplin/releases/tag/v1.0.189) (p) | 2020-03-04T17:27:15Z | 350 | 100 | 98 | 548 |
| [v1.0.187](https://github.com/laurent22/joplin/releases/tag/v1.0.187) (p) | 2020-03-01T12:31:06Z | 925 | 234 | 271 | 1,430 |
| [v1.0.179](https://github.com/laurent22/joplin/releases/tag/v1.0.179) | 2020-01-24T22:42:41Z | 71,304 | 28,615 | 22,554 | 122,473 |
| [v1.0.179](https://github.com/laurent22/joplin/releases/tag/v1.0.179) | 2020-01-24T22:42:41Z | 71,304 | 28,618 | 22,554 | 122,476 |
| [v1.0.178](https://github.com/laurent22/joplin/releases/tag/v1.0.178) | 2020-01-20T19:06:45Z | 17,592 | 5,967 | 2,590 | 26,149 |
| [v1.0.177](https://github.com/laurent22/joplin/releases/tag/v1.0.177) (p) | 2019-12-30T14:40:40Z | 1,950 | 442 | 698 | 3,090 |
| [v1.0.177](https://github.com/laurent22/joplin/releases/tag/v1.0.177) (p) | 2019-12-30T14:40:40Z | 1,950 | 442 | 699 | 3,091 |
| [v1.0.176](https://github.com/laurent22/joplin/releases/tag/v1.0.176) (p) | 2019-12-14T10:36:44Z | 3,128 | 2,538 | 470 | 6,136 |
| [v1.0.175](https://github.com/laurent22/joplin/releases/tag/v1.0.175) | 2019-12-08T11:48:47Z | 72,849 | 16,926 | 16,542 | 106,317 |
| [v1.0.175](https://github.com/laurent22/joplin/releases/tag/v1.0.175) | 2019-12-08T11:48:47Z | 72,886 | 16,928 | 16,545 | 106,359 |
| [v1.0.174](https://github.com/laurent22/joplin/releases/tag/v1.0.174) | 2019-11-12T18:20:58Z | 30,487 | 11,733 | 8,224 | 50,444 |
| [v1.0.173](https://github.com/laurent22/joplin/releases/tag/v1.0.173) | 2019-11-11T08:33:35Z | 5,103 | 2,083 | 749 | 7,935 |
| [v1.0.170](https://github.com/laurent22/joplin/releases/tag/v1.0.170) | 2019-10-13T22:13:04Z | 27,584 | 8,759 | 7,680 | 44,023 |
| [v1.0.169](https://github.com/laurent22/joplin/releases/tag/v1.0.169) | 2019-09-27T18:35:13Z | 17,155 | 5,925 | 3,757 | 26,837 |
| [v1.0.170](https://github.com/laurent22/joplin/releases/tag/v1.0.170) | 2019-10-13T22:13:04Z | 27,585 | 8,759 | 7,680 | 44,024 |
| [v1.0.169](https://github.com/laurent22/joplin/releases/tag/v1.0.169) | 2019-09-27T18:35:13Z | 17,157 | 5,925 | 3,757 | 26,839 |
| [v1.0.168](https://github.com/laurent22/joplin/releases/tag/v1.0.168) | 2019-09-25T21:21:38Z | 5,336 | 2,277 | 720 | 8,333 |
| [v1.0.167](https://github.com/laurent22/joplin/releases/tag/v1.0.167) | 2019-09-10T08:48:37Z | 16,811 | 5,708 | 3,706 | 26,225 |
| [v1.0.166](https://github.com/laurent22/joplin/releases/tag/v1.0.166) | 2019-09-09T17:35:54Z | 1,961 | 565 | 239 | 2,765 |
| [v1.0.165](https://github.com/laurent22/joplin/releases/tag/v1.0.165) | 2019-08-14T21:46:29Z | 18,975 | 6,979 | 5,467 | 31,421 |
| [v1.0.165](https://github.com/laurent22/joplin/releases/tag/v1.0.165) | 2019-08-14T21:46:29Z | 18,976 | 6,979 | 5,467 | 31,422 |
| [v1.0.161](https://github.com/laurent22/joplin/releases/tag/v1.0.161) | 2019-07-13T18:30:00Z | 19,299 | 6,356 | 4,139 | 29,794 |
| [v1.0.160](https://github.com/laurent22/joplin/releases/tag/v1.0.160) | 2019-06-15T00:21:40Z | 30,601 | 7,751 | 8,106 | 46,458 |
| [v1.0.160](https://github.com/laurent22/joplin/releases/tag/v1.0.160) | 2019-06-15T00:21:40Z | 30,602 | 7,751 | 8,106 | 46,459 |
| [v1.0.159](https://github.com/laurent22/joplin/releases/tag/v1.0.159) | 2019-06-08T00:00:19Z | 5,198 | 2,182 | 1,117 | 8,497 |
| [v1.0.158](https://github.com/laurent22/joplin/releases/tag/v1.0.158) | 2019-05-27T19:01:18Z | 9,820 | 3,545 | 1,939 | 15,304 |
| [v1.0.157](https://github.com/laurent22/joplin/releases/tag/v1.0.157) | 2019-05-26T17:55:53Z | 2,183 | 848 | 294 | 3,325 |
@@ -174,14 +174,14 @@
| [v1.0.145](https://github.com/laurent22/joplin/releases/tag/v1.0.145) | 2019-05-03T09:16:53Z | 7,012 | 2,865 | 1,440 | 11,317 |
| [v1.0.143](https://github.com/laurent22/joplin/releases/tag/v1.0.143) | 2019-04-22T10:51:38Z | 11,922 | 3,554 | 2,783 | 18,259 |
| [v1.0.142](https://github.com/laurent22/joplin/releases/tag/v1.0.142) | 2019-04-02T16:44:51Z | 14,693 | 4,569 | 4,730 | 23,992 |
| [v1.0.140](https://github.com/laurent22/joplin/releases/tag/v1.0.140) | 2019-03-10T20:59:58Z | 13,634 | 4,175 | 3,292 | 21,101 |
| [v1.0.140](https://github.com/laurent22/joplin/releases/tag/v1.0.140) | 2019-03-10T20:59:58Z | 13,634 | 4,175 | 3,294 | 21,103 |
| [v1.0.139](https://github.com/laurent22/joplin/releases/tag/v1.0.139) (p) | 2019-03-09T10:06:48Z | 128 | 67 | 49 | 244 |
| [v1.0.138](https://github.com/laurent22/joplin/releases/tag/v1.0.138) (p) | 2019-03-03T17:23:00Z | 156 | 93 | 87 | 336 |
| [v1.0.137](https://github.com/laurent22/joplin/releases/tag/v1.0.137) (p) | 2019-03-03T01:12:51Z | 596 | 62 | 86 | 744 |
| [v1.0.135](https://github.com/laurent22/joplin/releases/tag/v1.0.135) | 2019-02-27T23:36:57Z | 12,556 | 3,963 | 4,081 | 20,600 |
| [v1.0.135](https://github.com/laurent22/joplin/releases/tag/v1.0.135) | 2019-02-27T23:36:57Z | 12,557 | 3,963 | 4,081 | 20,601 |
| [v1.0.134](https://github.com/laurent22/joplin/releases/tag/v1.0.134) | 2019-02-27T10:21:44Z | 1,472 | 573 | 222 | 2,267 |
| [v1.0.132](https://github.com/laurent22/joplin/releases/tag/v1.0.132) | 2019-02-26T23:02:05Z | 1,092 | 456 | 99 | 1,647 |
| [v1.0.127](https://github.com/laurent22/joplin/releases/tag/v1.0.127) | 2019-02-14T23:12:48Z | 9,817 | 3,176 | 2,933 | 15,926 |
| [v1.0.127](https://github.com/laurent22/joplin/releases/tag/v1.0.127) | 2019-02-14T23:12:48Z | 9,818 | 3,176 | 2,933 | 15,927 |
| [v1.0.126](https://github.com/laurent22/joplin/releases/tag/v1.0.126) (p) | 2019-02-09T19:46:16Z | 938 | 78 | 120 | 1,136 |
| [v1.0.125](https://github.com/laurent22/joplin/releases/tag/v1.0.125) | 2019-01-26T18:14:33Z | 10,266 | 3,563 | 1,706 | 15,535 |
| [v1.0.120](https://github.com/laurent22/joplin/releases/tag/v1.0.120) | 2019-01-10T21:42:53Z | 15,610 | 5,207 | 6,521 | 27,338 |
@@ -191,14 +191,14 @@
| [v1.0.116](https://github.com/laurent22/joplin/releases/tag/v1.0.116) | 2018-11-20T19:09:24Z | 3,479 | 1,127 | 717 | 5,323 |
| [v1.0.115](https://github.com/laurent22/joplin/releases/tag/v1.0.115) | 2018-11-16T16:52:02Z | 3,662 | 1,307 | 803 | 5,772 |
| [v1.0.114](https://github.com/laurent22/joplin/releases/tag/v1.0.114) | 2018-10-24T20:14:10Z | 11,401 | 3,505 | 3,833 | 18,739 |
| [v1.0.111](https://github.com/laurent22/joplin/releases/tag/v1.0.111) | 2018-09-30T20:15:09Z | 12,067 | 3,315 | 3,674 | 19,056 |
| [v1.0.111](https://github.com/laurent22/joplin/releases/tag/v1.0.111) | 2018-09-30T20:15:09Z | 12,068 | 3,315 | 3,674 | 19,057 |
| [v1.0.110](https://github.com/laurent22/joplin/releases/tag/v1.0.110) | 2018-09-29T12:29:21Z | 966 | 413 | 121 | 1,500 |
| [v1.0.109](https://github.com/laurent22/joplin/releases/tag/v1.0.109) | 2018-09-27T18:01:41Z | 2,107 | 709 | 331 | 3,147 |
| [v1.0.108](https://github.com/laurent22/joplin/releases/tag/v1.0.108) (p) | 2018-09-29T18:49:29Z | 35 | 26 | 17 | 78 |
| [v1.0.107](https://github.com/laurent22/joplin/releases/tag/v1.0.107) | 2018-09-16T19:51:07Z | 7,155 | 2,141 | 1,712 | 11,008 |
| [v1.0.106](https://github.com/laurent22/joplin/releases/tag/v1.0.106) | 2018-09-08T15:23:40Z | 4,563 | 1,462 | 321 | 6,346 |
| [v1.0.105](https://github.com/laurent22/joplin/releases/tag/v1.0.105) | 2018-09-05T11:29:36Z | 4,661 | 1,594 | 1,458 | 7,713 |
| [v1.0.104](https://github.com/laurent22/joplin/releases/tag/v1.0.104) | 2018-06-28T20:25:36Z | 15,062 | 4,704 | 7,358 | 27,124 |
| [v1.0.104](https://github.com/laurent22/joplin/releases/tag/v1.0.104) | 2018-06-28T20:25:36Z | 15,062 | 4,705 | 7,359 | 27,126 |
| [v1.0.103](https://github.com/laurent22/joplin/releases/tag/v1.0.103) | 2018-06-21T19:38:13Z | 2,059 | 892 | 683 | 3,634 |
| [v1.0.101](https://github.com/laurent22/joplin/releases/tag/v1.0.101) | 2018-06-17T18:35:11Z | 1,315 | 612 | 412 | 2,339 |
| [v1.0.100](https://github.com/laurent22/joplin/releases/tag/v1.0.100) | 2018-06-14T17:41:43Z | 887 | 439 | 249 | 1,575 |
@@ -207,16 +207,16 @@
| [v1.0.96](https://github.com/laurent22/joplin/releases/tag/v1.0.96) | 2018-05-26T16:36:39Z | 2,726 | 1,229 | 1,705 | 5,660 |
| [v1.0.95](https://github.com/laurent22/joplin/releases/tag/v1.0.95) | 2018-05-25T13:04:30Z | 424 | 224 | 126 | 774 |
| [v1.0.94](https://github.com/laurent22/joplin/releases/tag/v1.0.94) | 2018-05-21T20:52:59Z | 1,138 | 590 | 402 | 2,130 |
| [v1.0.93](https://github.com/laurent22/joplin/releases/tag/v1.0.93) | 2018-05-14T11:36:01Z | 1,795 | 1,189 | 764 | 3,748 |
| [v1.0.93](https://github.com/laurent22/joplin/releases/tag/v1.0.93) | 2018-05-14T11:36:01Z | 1,795 | 1,190 | 764 | 3,749 |
| [v1.0.91](https://github.com/laurent22/joplin/releases/tag/v1.0.91) | 2018-05-10T14:48:04Z | 831 | 557 | 315 | 1,703 |
| [v1.0.89](https://github.com/laurent22/joplin/releases/tag/v1.0.89) | 2018-05-09T13:05:05Z | 500 | 237 | 116 | 853 |
| [v1.0.85](https://github.com/laurent22/joplin/releases/tag/v1.0.85) | 2018-05-01T21:08:24Z | 1,656 | 956 | 638 | 3,250 |
| [v1.0.83](https://github.com/laurent22/joplin/releases/tag/v1.0.83) | 2018-04-04T19:43:58Z | 5,065 | 2,536 | 2,663 | 10,264 |
| [v1.0.83](https://github.com/laurent22/joplin/releases/tag/v1.0.83) | 2018-04-04T19:43:58Z | 5,073 | 2,536 | 2,663 | 10,272 |
| [v1.0.82](https://github.com/laurent22/joplin/releases/tag/v1.0.82) | 2018-03-31T19:16:31Z | 696 | 411 | 127 | 1,234 |
| [v1.0.81](https://github.com/laurent22/joplin/releases/tag/v1.0.81) | 2018-03-28T08:13:58Z | 1,003 | 602 | 788 | 2,393 |
| [v1.0.79](https://github.com/laurent22/joplin/releases/tag/v1.0.79) | 2018-03-23T18:00:11Z | 934 | 544 | 386 | 1,864 |
| [v1.0.78](https://github.com/laurent22/joplin/releases/tag/v1.0.78) | 2018-03-17T15:27:18Z | 1,315 | 898 | 877 | 3,090 |
| [v1.0.77](https://github.com/laurent22/joplin/releases/tag/v1.0.77) | 2018-03-16T15:12:35Z | 182 | 109 | 51 | 342 |
| [v1.0.77](https://github.com/laurent22/joplin/releases/tag/v1.0.77) | 2018-03-16T15:12:35Z | 182 | 110 | 51 | 343 |
| [v1.0.72](https://github.com/laurent22/joplin/releases/tag/v1.0.72) | 2018-03-14T09:44:35Z | 409 | 262 | 62 | 733 |
| [v1.0.70](https://github.com/laurent22/joplin/releases/tag/v1.0.70) | 2018-02-28T20:04:30Z | 1,858 | 1,056 | 1,260 | 4,174 |
| [v1.0.67](https://github.com/laurent22/joplin/releases/tag/v1.0.67) | 2018-02-19T22:51:08Z | 1,818 | 609 | 0 | 2,427 |
@@ -224,18 +224,18 @@
| [v1.0.65](https://github.com/laurent22/joplin/releases/tag/v1.0.65) | 2018-02-17T20:02:25Z | 197 | 134 | 137 | 468 |
| [v1.0.64](https://github.com/laurent22/joplin/releases/tag/v1.0.64) | 2018-02-16T00:58:20Z | 1,088 | 549 | 1,127 | 2,764 |
| [v1.0.63](https://github.com/laurent22/joplin/releases/tag/v1.0.63) | 2018-02-14T19:40:36Z | 305 | 166 | 97 | 568 |
| [v1.0.62](https://github.com/laurent22/joplin/releases/tag/v1.0.62) | 2018-02-12T20:19:58Z | 563 | 305 | 372 | 1,240 |
| [v0.10.61](https://github.com/laurent22/joplin/releases/tag/v0.10.61) | 2018-02-08T18:27:39Z | 975 | 637 | 968 | 2,580 |
| [v1.0.62](https://github.com/laurent22/joplin/releases/tag/v1.0.62) | 2018-02-12T20:19:58Z | 563 | 306 | 372 | 1,241 |
| [v0.10.61](https://github.com/laurent22/joplin/releases/tag/v0.10.61) | 2018-02-08T18:27:39Z | 975 | 639 | 968 | 2,582 |
| [v0.10.60](https://github.com/laurent22/joplin/releases/tag/v0.10.60) | 2018-02-06T13:09:56Z | 725 | 526 | 556 | 1,807 |
| [v0.10.54](https://github.com/laurent22/joplin/releases/tag/v0.10.54) | 2018-01-31T20:21:30Z | 1,823 | 1,465 | 327 | 3,615 |
| [v0.10.52](https://github.com/laurent22/joplin/releases/tag/v0.10.52) | 2018-01-31T19:25:18Z | 50 | 639 | 19 | 708 |
| [v0.10.51](https://github.com/laurent22/joplin/releases/tag/v0.10.51) | 2018-01-28T18:47:02Z | 1,332 | 1,604 | 331 | 3,267 |
| [v0.10.48](https://github.com/laurent22/joplin/releases/tag/v0.10.48) | 2018-01-23T11:19:51Z | 1,968 | 1,757 | 35 | 3,760 |
| [v0.10.47](https://github.com/laurent22/joplin/releases/tag/v0.10.47) | 2018-01-16T17:27:17Z | 1,233 | 1,274 | 71 | 2,578 |
| [v0.10.47](https://github.com/laurent22/joplin/releases/tag/v0.10.47) | 2018-01-16T17:27:17Z | 1,233 | 1,276 | 71 | 2,580 |
| [v0.10.43](https://github.com/laurent22/joplin/releases/tag/v0.10.43) | 2018-01-08T10:12:10Z | 3,445 | 2,361 | 1,212 | 7,018 |
| [v0.10.41](https://github.com/laurent22/joplin/releases/tag/v0.10.41) | 2018-01-05T20:38:12Z | 1,040 | 1,554 | 246 | 2,840 |
| [v0.10.40](https://github.com/laurent22/joplin/releases/tag/v0.10.40) | 2018-01-02T23:16:57Z | 1,598 | 1,794 | 343 | 3,735 |
| [v0.10.39](https://github.com/laurent22/joplin/releases/tag/v0.10.39) | 2017-12-11T21:19:44Z | 5,828 | 4,298 | 3,198 | 13,324 |
| [v0.10.39](https://github.com/laurent22/joplin/releases/tag/v0.10.39) | 2017-12-11T21:19:44Z | 5,828 | 4,299 | 3,198 | 13,325 |
| [v0.10.38](https://github.com/laurent22/joplin/releases/tag/v0.10.38) | 2017-12-08T10:12:06Z | 1,052 | 1,235 | 310 | 2,597 |
| [v0.10.37](https://github.com/laurent22/joplin/releases/tag/v0.10.37) | 2017-12-07T19:38:05Z | 268 | 850 | 87 | 1,205 |
| [v0.10.36](https://github.com/laurent22/joplin/releases/tag/v0.10.36) | 2017-12-05T09:34:40Z | 1,018 | 1,362 | 443 | 2,823 |
@@ -244,11 +244,11 @@
| [v0.10.33](https://github.com/laurent22/joplin/releases/tag/v0.10.33) | 2017-12-02T13:20:39Z | 64 | 664 | 27 | 755 |
| [v0.10.31](https://github.com/laurent22/joplin/releases/tag/v0.10.31) | 2017-12-01T09:56:44Z | 896 | 1,456 | 411 | 2,763 |
| [v0.10.30](https://github.com/laurent22/joplin/releases/tag/v0.10.30) | 2017-11-30T20:28:16Z | 726 | 1,374 | 424 | 2,524 |
| [v0.10.28](https://github.com/laurent22/joplin/releases/tag/v0.10.28) | 2017-11-30T01:07:46Z | 1,346 | 1,706 | 879 | 3,931 |
| [v0.10.28](https://github.com/laurent22/joplin/releases/tag/v0.10.28) | 2017-11-30T01:07:46Z | 1,346 | 1,707 | 879 | 3,932 |
| [v0.10.26](https://github.com/laurent22/joplin/releases/tag/v0.10.26) | 2017-11-29T16:02:17Z | 191 | 706 | 265 | 1,162 |
| [v0.10.25](https://github.com/laurent22/joplin/releases/tag/v0.10.25) | 2017-11-24T14:27:49Z | 152 | 701 | 6,521 | 7,374 |
| [v0.10.25](https://github.com/laurent22/joplin/releases/tag/v0.10.25) | 2017-11-24T14:27:49Z | 152 | 702 | 6,524 | 7,378 |
| [v0.10.23](https://github.com/laurent22/joplin/releases/tag/v0.10.23) | 2017-11-21T19:38:41Z | 136 | 653 | 32 | 821 |
| [v0.10.22](https://github.com/laurent22/joplin/releases/tag/v0.10.22) | 2017-11-20T21:45:57Z | 89 | 650 | 25 | 764 |
| [v0.10.21](https://github.com/laurent22/joplin/releases/tag/v0.10.21) | 2017-11-18T00:53:15Z | 56 | 643 | 18 | 717 |
| [v0.10.20](https://github.com/laurent22/joplin/releases/tag/v0.10.20) | 2017-11-17T17:18:25Z | 37 | 654 | 27 | 718 |
| [v0.10.19](https://github.com/laurent22/joplin/releases/tag/v0.10.19) | 2017-11-20T18:59:48Z | 26 | 650 | 21 | 697 |
| [v0.10.19](https://github.com/laurent22/joplin/releases/tag/v0.10.19) | 2017-11-20T18:59:48Z | 26 | 651 | 21 | 698 |