You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-01-02 00:08:04 +02:00
Compare commits
35 Commits
android-v2
...
server_ci_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b611b441c5 | ||
|
|
1962f03adf | ||
|
|
66fa23cdd8 | ||
|
|
6eec0dd58e | ||
|
|
dc67eace24 | ||
|
|
4fecb083a7 | ||
|
|
ef23d99b47 | ||
|
|
01048f5971 | ||
|
|
06ce4adc20 | ||
|
|
2fd8f39293 | ||
|
|
3627fa14e1 | ||
|
|
6950c40b12 | ||
|
|
a6884a2ee4 | ||
|
|
7eb1d89d66 | ||
|
|
920847245f | ||
|
|
605f12552e | ||
|
|
0689db48de | ||
|
|
f224282a27 | ||
|
|
c0a8c330a9 | ||
|
|
4ce58fa486 | ||
|
|
171b4b126d | ||
|
|
e4742f8b6a | ||
|
|
a3703cc895 | ||
|
|
ab6aeb7455 | ||
|
|
d96f8ee228 | ||
|
|
5981227c06 | ||
|
|
8e54a65ca5 | ||
|
|
7985958f03 | ||
|
|
1e4cc16770 | ||
|
|
75f729620e | ||
|
|
799fe81449 | ||
|
|
080c3cc7dc | ||
|
|
82defbdd7b | ||
|
|
c19e59f5da | ||
|
|
0e11273c45 |
@@ -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
|
||||
|
||||
9
.github/scripts/run_ci.sh
vendored
9
.github/scripts/run_ci.sh
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/github-actions-main.yml
vendored
3
.github/workflows/github-actions-main.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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
|
||||
|
||||
3
BUILD.md
3
BUILD.md
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
39
README.md
39
README.md
@@ -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&mtm_kwd=joplinapp&mtm_source=joplinapp-github&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&mtm_kwd=joplinapp&mtm_source=joplinapp-github&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
|
||||
|
||||
|
||||
@@ -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&business=E8JMYD2LQ8MMA&lc=GB&item_name=Joplin+Development&currency_code=EUR&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>
|
||||
|
||||
@@ -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&business=E8JMYD2LQ8MMA&lc=GB&item_name=Joplin+Development&currency_code=EUR&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>
|
||||
|
||||
@@ -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&mtm_kwd=joplinapp&mtm_source=joplinapp-github&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&mtm_kwd=joplinapp&mtm_source=joplinapp-github&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 "OneDrive" as the synchronisation target in the <a href="/config_screen/">Configuration screen</a>. Then, to initiate the synchronisation process, click on the "Synchronise" 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 "AWS S3 (Beta)" 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 "S3 (Beta)" 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 & AWS secret:</strong> IAM user's programmatic access key. To create a new key & 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 & S3 Secret Key:</strong> The User's programmatic access key. To create a new key & 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 "eu-west1" 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>{
|
||||
"Version": "2012-10-17",
|
||||
@@ -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 "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.</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>
|
||||
|
||||
@@ -571,10 +571,10 @@
|
||||
<br />
|
||||
|
||||
<div class="text-center sponsors-org">
|
||||
<a class="sponsor-org-item" href="https://usrigging.com/"><img title="U.S. Ringing Supply" src="/images/sponsors/RingingSupply.svg"></a>
|
||||
<a class="sponsor-org-item" href="https://seirei.ne.jp"><img title="Serei Network" src="/images/sponsors/SeireiNetwork.png"></a>
|
||||
<a class="sponsor-org-item" href="https://tranio.com/italy/"><img title="Tranio" src="/images/sponsors/Tranio.png"></a>
|
||||
<a class="sponsor-org-item" href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" src="/images/sponsors/HostingDe.png"></a>
|
||||
<a class="sponsor-org-item" href="https://tranio.com/italy/"><img title="Tranio" src="/images/sponsors/Tranio.png"></a>
|
||||
<a class="sponsor-org-item" href="https://seirei.ne.jp"><img title="Serei Network" src="/images/sponsors/SeireiNetwork.png"></a>
|
||||
<a class="sponsor-org-item" href="https://usrigging.com/"><img title="U.S. Ringing Supply" src="/images/sponsors/RingingSupply.svg"></a>
|
||||
</div>
|
||||
|
||||
<div class="text-center sponsors-github">
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -134,6 +134,7 @@ body {
|
||||
|
||||
.App .Folders select {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.App .Tags input {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
14
packages/app-desktop/package-lock.json
generated
14
packages/app-desktop/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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_)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
879
packages/app-mobile/package-lock.json
generated
879
packages/app-mobile/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class FsDriverDummy {
|
||||
constructor() {}
|
||||
appendFileSync() {}
|
||||
writeBinaryFile() {}
|
||||
readFile() {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
3809
packages/lib/package-lock.json
generated
3809
packages/lib/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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]();
|
||||
|
||||
@@ -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, ' '));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum TaskId {
|
||||
HandleBetaUserEmails = 4,
|
||||
HandleFailedPaymentSubscriptions = 5,
|
||||
DeleteExpiredSessions = 6,
|
||||
CompressOldChanges = 7,
|
||||
}
|
||||
|
||||
export enum RunType {
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
1
readme/connection_check.md
Normal file
1
readme/connection_check.md
Normal 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/
|
||||
@@ -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/)
|
||||
|
||||
204
readme/stats.md
204
readme/stats.md
@@ -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 |
|
||||
Reference in New Issue
Block a user