1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-23 23:33:01 +02:00

Compare commits

..

49 Commits

Author SHA1 Message Date
Laurent Cozic
a5b5ef1886 Merge branch 'dev' into server_content_drivers 2021-11-09 15:33:34 +00:00
Laurent Cozic
7b3ad32103 Update translations 2021-11-09 15:28:38 +00:00
Laurent Cozic
3745cd7cb0 Tools: Do not process context when running build-translation tool 2021-11-09 15:26:45 +00:00
Laurent Cozic
920f2d9655 Revert "Update translations"
This reverts commit f800ca0269.

Reverting for now due to some translations being incorrectly marked as
fuzzy.
2021-11-09 13:49:20 +00:00
Laurent Cozic
f800ca0269 Update translations 2021-11-09 13:16:09 +00:00
Laurent Cozic
33be306d01 Tools: Fixed missing translations when running build-translation tool 2021-11-09 13:14:41 +00:00
Laurent Cozic
3782255c27 Tools: Fixed build-translation script to handle .ts and .tsx files directly 2021-11-09 12:20:07 +00:00
Laurent Cozic
68f77f6bbc fix 2021-11-08 17:46:29 +00:00
Laurent
0ab235273b Tools: Detect missing translation strings on CI (#5688) 2021-11-08 17:10:33 +00:00
Laurent Cozic
75256613cc Security: Ensure Markdown links that contain single quotes are correctly escaped 2021-11-08 15:39:45 +00:00
Helmut K. C. Tessarek
b328094033 update en_US.po 2021-11-08 10:30:18 -05:00
Helmut K. C. Tessarek
4f0f1af5d1 Update translations 2021-11-08 10:22:16 -05:00
Laurent Cozic
021ce14348 Server v2.6.3 2021-11-08 15:21:20 +00:00
Laurent Cozic
b402bc7ff7 Merge branch 'dev' into server_content_drivers 2021-11-08 15:06:06 +00:00
Laurent Cozic
0ed0690bf8 rename 2021-11-08 14:58:10 +00:00
Laurent Cozic
467b1156cc s3 2021-11-08 14:24:42 +00:00
Laurent Cozic
c4017e52dc Fixed a few strings 2021-11-08 10:00:11 +00:00
Laurent Cozic
ec2c1741a2 Tools: Fixed package version numbers 2021-11-07 17:36:48 +00:00
Laurent Cozic
6a9d9f6542 Merge branch 'dev' into server_content_drivers 2021-11-07 17:30:04 +00:00
Laurent Cozic
3e5ad0a374 Desktop, Cli: Fixes #5653: Long resource filenames were being incorrectly cut 2021-11-07 16:41:39 +00:00
Laurent Cozic
05e390d48b Tools: Fixed build-translation script 2021-11-07 15:41:04 +00:00
Laurent Cozic
31ce0f46e0 Tools: Show more info in IOS release script 2021-11-07 11:55:34 +00:00
Laurent Cozic
d19551b984 Chore: Set correct iOS version ID 2021-11-07 11:55:11 +00:00
Laurent Cozic
dacd697f80 iOS: Fixes #5671: Fixed iOS 12 crash that prevents the app from starting 2021-11-07 11:53:39 +00:00
Laurent Cozic
70d5c7a648 Server: Set resource content size when viewing published note 2021-11-07 11:47:37 +00:00
Laurent Cozic
69b413ce2b storage table 2021-11-07 11:46:25 +00:00
Daniel Bretoi
42caab6bde Doc: CLI: update to include local filesystem sync (#5684)
* terminal doc update to include local filesystem sync

* fix typo
2021-11-07 00:32:18 -04:00
NiceYoyo
01b63ad263 All: Translation: Update de_DE.po (#5682)
* Update de_DE.po

* Update de_DE.po
2021-11-07 00:30:04 -04:00
felipeviggiano
ae4013d2f7 All: Translation: Update pt_BR.po (#5680)
* Updates on pt_BR.po file

* Updates on pt_BR.po file
2021-11-07 00:29:38 -04:00
Laurent Cozic
e3d6334372 id 2021-11-06 19:51:32 +00:00
Laurent Cozic
cc4c50c219 rename 2021-11-06 16:23:43 +00:00
Laurent Cozic
5d646f7ced env 2021-11-06 15:33:07 +00:00
Laurent Cozic
fa3612405c tests 2021-11-05 12:05:03 +00:00
Laurent Cozic
20df46c066 fallback 2021-11-05 11:43:27 +00:00
Laurent Cozic
9b0a659416 connection string 2021-11-04 18:00:37 +00:00
Laurent Cozic
298e85f115 iOS: Set min supported iOS version to 13.0 2021-11-04 16:43:34 +00:00
Laurent Cozic
a00e0e7043 Merge branch 'dev' into server_content_drivers 2021-11-04 15:46:55 +00:00
Laurent Cozic
9e1cb9db2c Server: Immediately ask user to set password after Stripe checkout 2021-11-04 12:49:51 +00:00
Laurent Cozic
373c041aa6 Doc: Fixed H3 header size 2021-11-03 17:39:30 +00:00
Laurent
af19865865 All, Server: Add support for sharing notes when E2EE is enabled (#5529) 2021-11-03 16:24:40 +00:00
Laurent Cozic
560523bdc2 tests 2021-10-26 19:09:33 +01:00
Laurent Cozic
a13242e803 Merge branch 'dev' into server_content_drivers 2021-10-26 17:58:47 +01:00
Laurent Cozic
72834fcfc4 clean up 2021-10-22 14:46:54 +01:00
Laurent Cozic
731142218b comment 2021-10-22 14:33:03 +01:00
Laurent Cozic
17b580b71b support fallback driver 2021-10-21 11:41:01 +01:00
Laurent Cozic
f7be45c236 tests 2021-10-20 12:18:56 +01:00
Laurent Cozic
b298861dc3 db driver 2021-10-19 19:44:43 +01:00
Laurent Cozic
2343de3763 Merge branch 'dev' into server_content_drivers 2021-10-19 17:52:46 +01:00
Laurent Cozic
abb37258d0 Content drivers 2021-10-12 18:56:42 +01:00
161 changed files with 219655 additions and 186926 deletions

View File

@@ -1320,6 +1320,9 @@ packages/lib/services/interop/InteropService_Exporter_Jex.js.map
packages/lib/services/interop/InteropService_Exporter_Md.d.ts
packages/lib/services/interop/InteropService_Exporter_Md.js
packages/lib/services/interop/InteropService_Exporter_Md.js.map
packages/lib/services/interop/InteropService_Exporter_Md.test.d.ts
packages/lib/services/interop/InteropService_Exporter_Md.test.js
packages/lib/services/interop/InteropService_Exporter_Md.test.js.map
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.d.ts
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js.map

View File

@@ -81,7 +81,7 @@ fi
# release randomly fail.
# =============================================================================
if [ "$IS_PULL_REQUEST" == "1" ]; then
if [ "$IS_PULL_REQUEST" == "1" ] || [ "$IS_DEV_BRANCH" = "1" ]; then
echo "Step: Running linter..."
npm run linter-ci ./
@@ -109,6 +109,27 @@ if [ "$IS_PULL_REQUEST" == "1" ]; then
fi
fi
# =============================================================================
# Check that we didn't lose any string due to gettext not being able to parse
# newly modified or added scripts. This is convenient to quickly view on GitHub
# what commit may have broken translation building. We run this on macOS because
# we need the latest version of gettext (and stable Ubuntu doesn't have it).
# =============================================================================
if [ "$IS_PULL_REQUEST" == "1" ] || [ "$IS_DEV_BRANCH" = "1" ]; then
if [ "$IS_MACOS" == "1" ]; then
echo "Step: Checking for lost translation strings..."
xgettext --version
node packages/tools/build-translation.js --missing-strings-check-only
testResult=$?
if [ $testResult -ne 0 ]; then
exit $testResult
fi
fi
fi
# =============================================================================
# Find out if we should run the build or not. Electron-builder gets stuck when
# building PRs so we disable it in this case. The Linux build should provide
@@ -124,13 +145,12 @@ if [ "$IS_PULL_REQUEST" == "1" ]; then
fi
# =============================================================================
# Prepare the Electron app and build it
# Build the Electron app or Docker image depending on the current tag.
#
# If the current tag is a desktop release tag (starts with "v", such as
# "v1.4.7"), we build and publish to github
#
# Otherwise we only build but don't publish to GitHub. It helps finding
# out any issue in pull requests and dev branch.
# "v1.4.7"), we build and publish to GitHub. Otherwise we only build but don't
# publish to GitHub. It helps finding out any issue in pull requests and dev
# branch.
# =============================================================================
cd "$ROOT_DIR/packages/app-desktop"

View File

@@ -19,6 +19,14 @@ jobs:
sudo apt-get update || true
sudo apt-get install -y gettext
sudo apt-get install -y libsecret-1-dev
sudo apt-get install -y translate-toolkit
- name: Install macOS dependencies
if: runner.os == 'macOS'
run: |
brew update
brew install gettext
brew install translate-toolkit
- name: Install Docker Engine
if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/server-v')

3
.gitignore vendored
View File

@@ -1303,6 +1303,9 @@ packages/lib/services/interop/InteropService_Exporter_Jex.js.map
packages/lib/services/interop/InteropService_Exporter_Md.d.ts
packages/lib/services/interop/InteropService_Exporter_Md.js
packages/lib/services/interop/InteropService_Exporter_Md.js.map
packages/lib/services/interop/InteropService_Exporter_Md.test.d.ts
packages/lib/services/interop/InteropService_Exporter_Md.test.js
packages/lib/services/interop/InteropService_Exporter_Md.test.js.map
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.d.ts
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js.map

View File

@@ -188,6 +188,11 @@ h2 {
padding-bottom: 0.5em;
}
h3 {
font-size: 1.3em;
margin-bottom: 0.8rem;
}
.front-page h1 {
font-size: 3em;
}

View File

@@ -505,47 +505,47 @@ Current translations:
<!-- LOCALE-TABLE-AUTO-GENERATED -->
&nbsp; | Language | Po File | Last translator | Percent done
---|---|---|---|---
<img src="https://joplinapp.org/images/flags/country-4x3/arableague.png" width="16px"/> | Arabic | [ar](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po) | [Whaell O](mailto:Whaell@protonmail.com) | 99%
<img src="https://joplinapp.org/images/flags/es/basque_country.png" width="16px"/> | Basque | [eu](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eu.po) | juan.abasolo@ehu.eus | 28%
<img src="https://joplinapp.org/images/flags/country-4x3/ba.png" width="16px"/> | Bosnian (Bosna i Hercegovina) | [bs_BA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bs_BA.po) | [Derviš T.](mailto:dervis.t@pm.me) | 71%
<img src="https://joplinapp.org/images/flags/country-4x3/bg.png" width="16px"/> | Bulgarian (България) | [bg_BG](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po) | | 55%
<img src="https://joplinapp.org/images/flags/es/catalonia.png" width="16px"/> | Catalan | [ca](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ca.po) | [Xavi Ivars](mailto:xavi.ivars@gmail.com) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/arableague.png" width="16px"/> | Arabic | [ar](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po) | [Whaell O](mailto:Whaell@protonmail.com) | 95%
<img src="https://joplinapp.org/images/flags/es/basque_country.png" width="16px"/> | Basque | [eu](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eu.po) | juan.abasolo@ehu.eus | 27%
<img src="https://joplinapp.org/images/flags/country-4x3/ba.png" width="16px"/> | Bosnian (Bosna i Hercegovina) | [bs_BA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bs_BA.po) | [Derviš T.](mailto:dervis.t@pm.me) | 68%
<img src="https://joplinapp.org/images/flags/country-4x3/bg.png" width="16px"/> | Bulgarian (България) | [bg_BG](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po) | | 54%
<img src="https://joplinapp.org/images/flags/es/catalonia.png" width="16px"/> | Catalan | [ca](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ca.po) | [Xavi Ivars](mailto:xavi.ivars@gmail.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/hr.png" width="16px"/> | Croatian (Hrvatska) | [hr_HR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hr_HR.po) | [Milo Ivir](mailto:mail@milotype.de) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/cz.png" width="16px"/> | Czech (Česká republika) | [cs_CZ](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po) | [Michal Stanke](mailto:michal@stanke.cz) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/dk.png" width="16px"/> | Dansk (Danmark) | [da_DK](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/da_DK.po) | Mustafa Al-Dailemi (dailemi@hotmail.com)Language-Team: | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/de.png" width="16px"/> | Deutsch (Deutschland) | [de_DE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/de_DE.po) | [marph91](mailto:martin.d@andix.de) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/ee.png" width="16px"/> | Eesti Keel (Eesti) | [et_EE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/et_EE.po) | | 54%
<img src="https://joplinapp.org/images/flags/country-4x3/cz.png" width="16px"/> | Czech (Česká republika) | [cs_CZ](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po) | [Michal Stanke](mailto:michal@stanke.cz) | 91%
<img src="https://joplinapp.org/images/flags/country-4x3/dk.png" width="16px"/> | Dansk (Danmark) | [da_DK](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/da_DK.po) | ERYpTION | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/de.png" width="16px"/> | Deutsch (Deutschland) | [de_DE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/de_DE.po) | [MrKanister](mailto:s.robin@tutanota.de) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/ee.png" width="16px"/> | Eesti Keel (Eesti) | [et_EE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/et_EE.po) | | 52%
<img src="https://joplinapp.org/images/flags/country-4x3/gb.png" width="16px"/> | English (United Kingdom) | [en_GB](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/en_GB.po) | | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/us.png" width="16px"/> | English (United States of America) | [en_US](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/en_US.po) | | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/es.png" width="16px"/> | Español (España) | [es_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po) | [Francisco Mora](mailto:francisco.m.collao@gmail.com) | 95%
<img src="https://joplinapp.org/images/flags/esperanto.png" width="16px"/> | Esperanto | [eo](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eo.po) | Marton Paulo | 31%
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | mrkaato | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/fr.png" width="16px"/> | Français (France) | [fr_FR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fr_FR.po) | Nicolas Viviani | 100%
<img src="https://joplinapp.org/images/flags/es/galicia.png" width="16px"/> | Galician (España) | [gl_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po) | [Marcos Lans](mailto:marcoslansgarza@gmail.com) | 36%
<img src="https://joplinapp.org/images/flags/country-4x3/id.png" width="16px"/> | Indonesian (Indonesia) | [id_ID](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po) | [eresytter](mailto:42007357+eresytter@users.noreply.github.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/it.png" width="16px"/> | Italiano (Italia) | [it_IT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po) | [Albano Battistella](mailto:albano_battistella@hotmail.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/hu.png" width="16px"/> | Magyar (Magyarország) | [hu_HU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po) | [Magyari Balázs](mailto:balmag@gmail.com) | 83%
<img src="https://joplinapp.org/images/flags/country-4x3/be.png" width="16px"/> | Nederlands (België, Belgique, Belgien) | [nl_BE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_BE.po) | | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/nl.png" width="16px"/> | Nederlands (Nederland) | [nl_NL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_NL.po) | [MetBril](mailto:metbril@users.noreply.github.com) | 90%
<img src="https://joplinapp.org/images/flags/country-4x3/no.png" width="16px"/> | Norwegian (Norge, Noreg) | [nb_NO](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po) | Alexander Dawson | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/ir.png" width="16px"/> | Persian | [fa](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fa.po) | [Kourosh Firoozbakht](mailto:kourox@protonmail.com) | 67%
<img src="https://joplinapp.org/images/flags/country-4x3/pl.png" width="16px"/> | Polski (Polska) | [pl_PL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po) | [konhi](mailto:hello.konhi@gmail.com) | 89%
<img src="https://joplinapp.org/images/flags/country-4x3/br.png" width="16px"/> | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po) | [Nicolas Suzuki](mailto:nicolas.suzuki@pm.me) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/pt.png" width="16px"/> | Português (Portugal) | [pt_PT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po) | [Diogo Caveiro](mailto:dcaveiro@yahoo.com) | 89%
<img src="https://joplinapp.org/images/flags/country-4x3/ro.png" width="16px"/> | Română | [ro](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ro.po) | [Cristi Duluta](mailto:cristi.duluta@gmail.com) | 62%
<img src="https://joplinapp.org/images/flags/country-4x3/si.png" width="16px"/> | Slovenian (Slovenija) | [sl_SI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sl_SI.po) | [Martin Korelič](mailto:martin.korelic@protonmail.com) | 90%
<img src="https://joplinapp.org/images/flags/country-4x3/se.png" width="16px"/> | Svenska | [sv](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sv.po) | [Jonatan Nyberg](mailto:jonatan@autistici.org) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/th.png" width="16px"/> | Thai (ประเทศไทย) | [th_TH](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/th_TH.po) | | 42%
<img src="https://joplinapp.org/images/flags/country-4x3/vi.png" width="16px"/> | Tiếng Việt | [vi](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/vi.po) | | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/tr.png" width="16px"/> | Türkçe (Türkiye) | [tr_TR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/tr_TR.po) | [Arda Kılıçdağı](mailto:arda@kilicdagi.com) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/ua.png" width="16px"/> | Ukrainian (Україна) | [uk_UA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po) | [Vyacheslav Andreykiv](mailto:vandreykiv@gmail.com) | 89%
<img src="https://joplinapp.org/images/flags/country-4x3/gr.png" width="16px"/> | Ελληνικά (Ελλάδα) | [el_GR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/el_GR.po) | [Harris Arvanitis](mailto:xaris@tuta.io) | 92%
<img src="https://joplinapp.org/images/flags/country-4x3/ru.png" width="16px"/> | Русский (Россия) | [ru_RU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po) | [Sergey Segeda](mailto:thesermanarm@gmail.com) | 99%
<img src="https://joplinapp.org/images/flags/country-4x3/rs.png" width="16px"/> | српски језик (Србија) | [sr_RS](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sr_RS.po) | | 80%
<img src="https://joplinapp.org/images/flags/country-4x3/cn.png" width="16px"/> | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po) | [南宫小骏](mailto:jackytsu@vip.qq.com) | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/tw.png" width="16px"/> | 中文 (繁體) | [zh_TW](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po) | [SiderealArt](mailto:nelson22768384@gmail.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/jp.png" width="16px"/> | 日本語 (日本) | [ja_JP](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ja_JP.po) | [genneko](mailto:genneko217@gmail.com) | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/kr.png" width="16px"/> | 한국어 | [ko](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po) | [Ji-Hyeon Gim](mailto:potatogim@potatogim.net) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/es.png" width="16px"/> | Español (España) | [es_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po) | [Francisco Mora](mailto:francisco.m.collao@gmail.com) | 96%
<img src="https://joplinapp.org/images/flags/esperanto.png" width="16px"/> | Esperanto | [eo](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eo.po) | Marton Paulo | 30%
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | mrkaato | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/fr.png" width="16px"/> | Français (France) | [fr_FR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fr_FR.po) | Nicolas Viviani | 96%
<img src="https://joplinapp.org/images/flags/es/galicia.png" width="16px"/> | Galician (España) | [gl_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po) | [Marcos Lans](mailto:marcoslansgarza@gmail.com) | 35%
<img src="https://joplinapp.org/images/flags/country-4x3/id.png" width="16px"/> | Indonesian (Indonesia) | [id_ID](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po) | [eresytter](mailto:42007357+eresytter@users.noreply.github.com) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/it.png" width="16px"/> | Italiano (Italia) | [it_IT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po) | [Albano Battistella](mailto:albano_battistella@hotmail.com) | 92%
<img src="https://joplinapp.org/images/flags/country-4x3/hu.png" width="16px"/> | Magyar (Magyarország) | [hu_HU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po) | [Magyari Balázs](mailto:balmag@gmail.com) | 80%
<img src="https://joplinapp.org/images/flags/country-4x3/be.png" width="16px"/> | Nederlands (België, Belgique, Belgien) | [nl_BE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_BE.po) | | 83%
<img src="https://joplinapp.org/images/flags/country-4x3/nl.png" width="16px"/> | Nederlands (Nederland) | [nl_NL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_NL.po) | [MetBril](mailto:metbril@users.noreply.github.com) | 87%
<img src="https://joplinapp.org/images/flags/country-4x3/no.png" width="16px"/> | Norwegian (Norge, Noreg) | [nb_NO](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po) | Alexander Dawson | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/ir.png" width="16px"/> | Persian | [fa](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fa.po) | [Kourosh Firoozbakht](mailto:kourox@protonmail.com) | 65%
<img src="https://joplinapp.org/images/flags/country-4x3/pl.png" width="16px"/> | Polski (Polska) | [pl_PL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po) | [konhi](mailto:hello.konhi@gmail.com) | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/br.png" width="16px"/> | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po) | [Felipe Viggiano](mailto:felipeviggiano@gmail.com) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/pt.png" width="16px"/> | Português (Portugal) | [pt_PT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po) | [Diogo Caveiro](mailto:dcaveiro@yahoo.com) | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/ro.png" width="16px"/> | Română | [ro](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ro.po) | [Cristi Duluta](mailto:cristi.duluta@gmail.com) | 60%
<img src="https://joplinapp.org/images/flags/country-4x3/si.png" width="16px"/> | Slovenian (Slovenija) | [sl_SI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sl_SI.po) | [Martin Korelič](mailto:martin.korelic@protonmail.com) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/se.png" width="16px"/> | Svenska | [sv](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sv.po) | [Jonatan Nyberg](mailto:jonatan@autistici.org) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/th.png" width="16px"/> | Thai (ประเทศไทย) | [th_TH](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/th_TH.po) | | 43%
<img src="https://joplinapp.org/images/flags/country-4x3/vi.png" width="16px"/> | Tiếng Việt | [vi](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/vi.po) | | 93%
<img src="https://joplinapp.org/images/flags/country-4x3/tr.png" width="16px"/> | Türkçe (Türkiye) | [tr_TR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/tr_TR.po) | [Arda Kılıçdağı](mailto:arda@kilicdagi.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/ua.png" width="16px"/> | Ukrainian (Україна) | [uk_UA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po) | [Vyacheslav Andreykiv](mailto:vandreykiv@gmail.com) | 85%
<img src="https://joplinapp.org/images/flags/country-4x3/gr.png" width="16px"/> | Ελληνικά (Ελλάδα) | [el_GR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/el_GR.po) | [Harris Arvanitis](mailto:xaris@tuta.io) | 89%
<img src="https://joplinapp.org/images/flags/country-4x3/ru.png" width="16px"/> | Русский (Россия) | [ru_RU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po) | [Sergey Segeda](mailto:thesermanarm@gmail.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/rs.png" width="16px"/> | српски језик (Србија) | [sr_RS](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sr_RS.po) | | 78%
<img src="https://joplinapp.org/images/flags/country-4x3/cn.png" width="16px"/> | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po) | [南宫小骏](mailto:jackytsu@vip.qq.com) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/tw.png" width="16px"/> | 中文 (繁體) | [zh_TW](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po) | [SiderealArt](mailto:nelson22768384@gmail.com) | 92%
<img src="https://joplinapp.org/images/flags/country-4x3/jp.png" width="16px"/> | 日本語 (日本) | [ja_JP](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ja_JP.po) | [genneko](mailto:genneko217@gmail.com) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/kr.png" width="16px"/> | 한국어 | [ko](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po) | [Ji-Hyeon Gim](mailto:potatogim@potatogim.net) | 91%
<!-- LOCALE-TABLE-AUTO-GENERATED -->
# Contributors

View File

@@ -167,7 +167,7 @@ const EncryptionConfigScreen = (props: Props) => {
mkComps.push(renderMasterKey(mk));
}
const headerComp = isEnabledMasterKeys ? <h2>{_('Encryption Keys')}</h2> : <a onClick={() => toggleShowDisabledMasterKeys() } style={{ ...theme.urlStyle, display: 'inline-block', marginBottom: 10 }} href="#">{showTable ? _('Hide disabled keys') : _('Show disabled keys')}</a>;
const headerComp = isEnabledMasterKeys ? <h2>{_('Encryption keys')}</h2> : <a onClick={() => toggleShowDisabledMasterKeys() } style={{ ...theme.urlStyle, display: 'inline-block', marginBottom: 10 }} href="#">{showTable ? _('Hide disabled keys') : _('Show disabled keys')}</a>;
const infoComp: any = null; // isEnabledMasterKeys ? <p>{'Note: Only one key is going to be used for encryption (the one marked as "active"). Any of the keys might be used for decryption, depending on how the notes or notebooks were originally encrypted.'}</p> : null;
const tableComp = !showTable ? null : (
<table>
@@ -247,7 +247,7 @@ const EncryptionConfigScreen = (props: Props) => {
{_('Encryption:')} <strong>{props.encryptionEnabled ? _('Enabled') : _('Disabled')}</strong>
</p>
<p>
{_('Public-Private Key Pair:')} <strong>{props.ppk ? _('Generated') : _('Not generated')}</strong>
{_('Public-private key pair:')} <strong>{props.ppk ? _('Generated') : _('Not generated')}</strong>
</p>
{decryptedItemsInfo}
{toggleButton}
@@ -319,7 +319,7 @@ const EncryptionConfigScreen = (props: Props) => {
nonExistingMasterKeySection = (
<div className="section">
<h2>{_('Missing Keys')}</h2>
<h2>{_('Missing keys')}</h2>
<p>{_('The keys with these IDs are used to encrypt some of your items, however the application does not currently have access to them. It is likely they will eventually be downloaded via synchronisation.')}</p>
<table>
<tbody>

View File

@@ -201,7 +201,7 @@ export default function(props: Props) {
}
}
const dialogTitle = mode === Mode.Set ? _('Manager master password') : `⚠️ ${_('Reset master password')} ⚠️`;
const dialogTitle = mode === Mode.Set ? _('Manage master password') : `⚠️ ${_('Reset master password')} ⚠️`;
const okButtonLabel = mode === Mode.Set ? _('Save') : `⚠️ ${_('Reset master password')} ⚠️`;
function renderDialogWrapper() {

View File

@@ -492,7 +492,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 77;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@@ -521,7 +521,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 77;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 9.0;
@@ -588,7 +588,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = (
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
@@ -641,7 +641,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = (
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
@@ -667,7 +667,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 77;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -698,7 +698,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 75;
CURRENT_PROJECT_VERSION = 77;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;

View File

@@ -1,9 +1,12 @@
require_relative '../node_modules/react-native/scripts/react_native_pods'
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
# Note: it was 13.4 to get @react-native-community/datetimepicker to work
# but it's probably not necessary actually. Just needed to upgrade XCode.
platform :ios, '11.0'
# Note: it was 13.4 to get @react-native-community/datetimepicker to work but
# it's probably not necessary actually. Just needed to upgrade XCode.
#
# 2021-11-04: Set to 13.0 because it crashes with 12.x
# https://github.com/laurent22/joplin/issues/5671
platform :ios, '13.0'
target 'Joplin' do
config = use_native_modules!

View File

@@ -324,7 +324,7 @@ PODS:
- React
- RNSecureRandom (1.0.0-rc.0):
- React
- RNShare (5.1.5):
- RNShare (7.2.1):
- React-Core
- RNVectorIcons (7.1.0):
- React
@@ -555,10 +555,10 @@ SPEC CHECKSUMS:
RNFS: 2bd9eb49dc82fa9676382f0585b992c424cd59df
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
RNSecureRandom: 1f19ad1492f7ed416b8fc79e92216a1f73f13a4c
RNShare: 9cdd23357981cf4dee275eb79239e860dccc0faf
RNShare: edd621a71124961e29a7ba43a84bd1c6f9980d88
RNVectorIcons: bc69e6a278b14842063605de32bec61f0b251a59
Yoga: 2b4a01651f42a32f82e6cef3830a3ba48088237f
PODFILE CHECKSUM: 9f8b595b05d63f54759fc6d9b1d2c5838fff9626
PODFILE CHECKSUM: 3ccf11f600ddb42a825b2bb9a341a19f5c891f2b
COCOAPODS: 1.10.2

View File

@@ -42,7 +42,7 @@
"react-native-quick-actions": "^0.3.13",
"react-native-rsa-native": "^2.0.4",
"react-native-securerandom": "^1.0.0-rc.0",
"react-native-share": "^5.1.5",
"react-native-share": "^7.2.1",
"react-native-side-menu": "^1.1.3",
"react-native-sqlite-storage": "^5.0.0",
"react-native-vector-icons": "^7.1.0",
@@ -10206,9 +10206,9 @@
}
},
"node_modules/react-native-share": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-5.1.5.tgz",
"integrity": "sha512-DfWCsoJEdIM873OmI2L8DccMZ9xGSS78t/LETDdGGCw0lgDhoSJx6LOhM3m/h/R9AJykfpGssonwxb8hN45paQ=="
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-7.2.1.tgz",
"integrity": "sha512-fZIhwnPDWStjGOEFnbthAMvaldZ4kR1QHNbhr/fNsOcOxrS0gzU6djjg0pqdkPLrpXMl+CeaDU1Rzxl/cJjg2A=="
},
"node_modules/react-native-side-menu": {
"version": "1.1.3",
@@ -14278,7 +14278,8 @@
"@react-native-community/clipboard": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@react-native-community/clipboard/-/clipboard-1.5.0.tgz",
"integrity": "sha512-XoujTQuXhPgQLVLn7HPt7615jBEGzJm1Nhos0COdreBIz3qWIi5noYZth8jBFctf8FG5tpe24XTZNDz5udgqQQ=="
"integrity": "sha512-XoujTQuXhPgQLVLn7HPt7615jBEGzJm1Nhos0COdreBIz3qWIi5noYZth8jBFctf8FG5tpe24XTZNDz5udgqQQ==",
"requires": {}
},
"@react-native-community/datetimepicker": {
"version": "3.0.3",
@@ -14291,12 +14292,14 @@
"@react-native-community/geolocation": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@react-native-community/geolocation/-/geolocation-2.0.2.tgz",
"integrity": "sha512-tTNXRCgnhJBu79mulQwzabXRpDqfh/uaDqfHVpvF0nX4NTpolpy6mvTRiFg7eWFPGRArsnZz1EYp6rHfJWGgEA=="
"integrity": "sha512-tTNXRCgnhJBu79mulQwzabXRpDqfh/uaDqfHVpvF0nX4NTpolpy6mvTRiFg7eWFPGRArsnZz1EYp6rHfJWGgEA==",
"requires": {}
},
"@react-native-community/netinfo": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-6.0.0.tgz",
"integrity": "sha512-Z9M8VGcF2IZVOo2x+oUStvpCW/8HjIRi4+iQCu5n+PhC7OqCQX58KYAzdBr///alIfRXiu6oMb+lK+rXQH1FvQ=="
"integrity": "sha512-Z9M8VGcF2IZVOo2x+oUStvpCW/8HjIRi4+iQCu5n+PhC7OqCQX58KYAzdBr///alIfRXiu6oMb+lK+rXQH1FvQ==",
"requires": {}
},
"@react-native-community/push-notification-ios": {
"version": "1.6.0",
@@ -14309,7 +14312,8 @@
"@react-native-community/slider": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-3.0.3.tgz",
"integrity": "sha512-8IeHfDwJ9/CTUwFs6x90VlobV3BfuPgNLjTgC6dRZovfCWigaZwVNIFFJnHBakK3pW2xErAPwhdvNR4JeNoYbw=="
"integrity": "sha512-8IeHfDwJ9/CTUwFs6x90VlobV3BfuPgNLjTgC6dRZovfCWigaZwVNIFFJnHBakK3pW2xErAPwhdvNR4JeNoYbw==",
"requires": {}
},
"@react-native/assets": {
"version": "1.0.0",
@@ -18529,7 +18533,8 @@
"joplin-rn-alarm-notification": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/joplin-rn-alarm-notification/-/joplin-rn-alarm-notification-1.0.3.tgz",
"integrity": "sha512-HZGDrLmYf6aMVgzk02w4DS9CjaTogE1hnOLdMDsrWkZzRskO6g3bZw+Bwlc63cCX4ZLZeeWIaABzHoWKAbLzpQ=="
"integrity": "sha512-HZGDrLmYf6aMVgzk02w4DS9CjaTogE1hnOLdMDsrWkZzRskO6g3bZw+Bwlc63cCX4ZLZeeWIaABzHoWKAbLzpQ==",
"requires": {}
},
"js-tokens": {
"version": "4.0.0",
@@ -18579,7 +18584,8 @@
"babel-core": {
"version": "7.0.0-bridge.0",
"resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz",
"integrity": "sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg=="
"integrity": "sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==",
"requires": {}
},
"braces": {
"version": "2.3.2",
@@ -20766,7 +20772,8 @@
"ws": {
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz",
"integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg=="
"integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==",
"requires": {}
}
}
},
@@ -20857,7 +20864,8 @@
"react-native-document-picker": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-4.0.0.tgz",
"integrity": "sha512-tjIOBBcyjv4j5E1MDL2OvEKNpXxQybLNkjjfpTyDUzek7grZ5eOvSlp6i/Y3EfuIGLByeaw++9R1SZtOij6R7w=="
"integrity": "sha512-tjIOBBcyjv4j5E1MDL2OvEKNpXxQybLNkjjfpTyDUzek7grZ5eOvSlp6i/Y3EfuIGLByeaw++9R1SZtOij6R7w==",
"requires": {}
},
"react-native-dropdownalert": {
"version": "3.1.2",
@@ -20908,7 +20916,8 @@
"react-native-file-viewer": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/react-native-file-viewer/-/react-native-file-viewer-2.1.4.tgz",
"integrity": "sha512-G3ko9lmqxT+lWhsDNy2K3Jes6xSMsUvlYwuwnRCNk2wC6hgYMeoeaiwDt8R3CdON781hB6Ej1eu3ir1QATtHXg=="
"integrity": "sha512-G3ko9lmqxT+lWhsDNy2K3Jes6xSMsUvlYwuwnRCNk2wC6hgYMeoeaiwDt8R3CdON781hB6Ej1eu3ir1QATtHXg==",
"requires": {}
},
"react-native-fs": {
"version": "2.16.6",
@@ -20927,7 +20936,8 @@
"react-native-image-resizer": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/react-native-image-resizer/-/react-native-image-resizer-1.3.0.tgz",
"integrity": "sha512-ymnx++RCZ8AJ1D/XAeK9tOLorYhFedeQvxeZSiisj4P49OP2mWdJ4z2uxxpVSb4SBA+i1hPYAtIAbOVObtUslQ=="
"integrity": "sha512-ymnx++RCZ8AJ1D/XAeK9tOLorYhFedeQvxeZSiisj4P49OP2mWdJ4z2uxxpVSb4SBA+i1hPYAtIAbOVObtUslQ==",
"requires": {}
},
"react-native-modal-datetime-picker": {
"version": "9.0.0",
@@ -20970,9 +20980,9 @@
}
},
"react-native-share": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-5.1.5.tgz",
"integrity": "sha512-DfWCsoJEdIM873OmI2L8DccMZ9xGSS78t/LETDdGGCw0lgDhoSJx6LOhM3m/h/R9AJykfpGssonwxb8hN45paQ=="
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-7.2.1.tgz",
"integrity": "sha512-fZIhwnPDWStjGOEFnbthAMvaldZ4kR1QHNbhr/fNsOcOxrS0gzU6djjg0pqdkPLrpXMl+CeaDU1Rzxl/cJjg2A=="
},
"react-native-side-menu": {
"version": "1.1.3",
@@ -20985,7 +20995,8 @@
"react-native-sqlite-storage": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-native-sqlite-storage/-/react-native-sqlite-storage-5.0.0.tgz",
"integrity": "sha512-c1Joq3/tO1nmIcP8SkRZNolPSbfvY8uZg5lXse0TmjIPC0qHVbk96IMvWGyly1TmYCIpxpuDRc0/xCffDbYIvg=="
"integrity": "sha512-c1Joq3/tO1nmIcP8SkRZNolPSbfvY8uZg5lXse0TmjIPC0qHVbk96IMvWGyly1TmYCIpxpuDRc0/xCffDbYIvg==",
"requires": {}
},
"react-native-vector-icons": {
"version": "7.1.0",
@@ -21005,7 +21016,8 @@
"react-native-version-info": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/react-native-version-info/-/react-native-version-info-1.1.0.tgz",
"integrity": "sha512-0QmJjdKyaW+G/TiOWkwzGVv1G3FPnWrPH5SYWloUpv8WA7onuQESYHdLyjfCUInYI/FHVeEynE2VomOOsda8wQ=="
"integrity": "sha512-0QmJjdKyaW+G/TiOWkwzGVv1G3FPnWrPH5SYWloUpv8WA7onuQESYHdLyjfCUInYI/FHVeEynE2VomOOsda8wQ==",
"requires": {}
},
"react-native-webview": {
"version": "10.9.2",

View File

@@ -15,8 +15,8 @@
"postinstall": "jetify && npm run build"
},
"dependencies": {
"@joplin/lib": "~2.5",
"@joplin/renderer": "~2.5",
"@joplin/lib": "~2.6",
"@joplin/renderer": "~2.6",
"@react-native-community/clipboard": "^1.5.0",
"@react-native-community/datetimepicker": "^3.0.3",
"@react-native-community/geolocation": "^2.0.2",
@@ -50,7 +50,7 @@
"react-native-quick-actions": "^0.3.13",
"react-native-rsa-native": "^2.0.4",
"react-native-securerandom": "^1.0.0-rc.0",
"react-native-share": "^5.1.5",
"react-native-share": "^7.2.1",
"react-native-side-menu": "^1.1.3",
"react-native-sqlite-storage": "^5.0.0",
"react-native-vector-icons": "^7.1.0",
@@ -73,7 +73,7 @@
"@codemirror/lang-markdown": "^0.18.4",
"@codemirror/state": "^0.18.7",
"@codemirror/view": "^0.18.19",
"@joplin/tools": "~2.5",
"@joplin/tools": "~2.6",
"@rollup/plugin-node-resolve": "^13.0.0",
"@rollup/plugin-typescript": "^8.2.1",
"@types/node": "^14.14.6",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -41,45 +41,45 @@ locales['uk_UA'] = require('./uk_UA.json');
locales['vi'] = require('./vi.json');
locales['zh_CN'] = require('./zh_CN.json');
locales['zh_TW'] = require('./zh_TW.json');
stats['ar'] = {"percentDone":99};
stats['eu'] = {"percentDone":28};
stats['bs_BA'] = {"percentDone":71};
stats['bg_BG'] = {"percentDone":55};
stats['ca'] = {"percentDone":99};
stats['ar'] = {"percentDone":95};
stats['eu'] = {"percentDone":27};
stats['bs_BA'] = {"percentDone":68};
stats['bg_BG'] = {"percentDone":54};
stats['ca'] = {"percentDone":95};
stats['hr_HR'] = {"percentDone":95};
stats['cs_CZ'] = {"percentDone":94};
stats['da_DK'] = {"percentDone":99};
stats['de_DE'] = {"percentDone":99};
stats['et_EE'] = {"percentDone":54};
stats['cs_CZ'] = {"percentDone":91};
stats['da_DK'] = {"percentDone":96};
stats['de_DE'] = {"percentDone":96};
stats['et_EE'] = {"percentDone":52};
stats['en_GB'] = {"percentDone":100};
stats['en_US'] = {"percentDone":100};
stats['es_ES'] = {"percentDone":95};
stats['eo'] = {"percentDone":31};
stats['fi_FI'] = {"percentDone":99};
stats['fr_FR'] = {"percentDone":100};
stats['gl_ES'] = {"percentDone":36};
stats['id_ID'] = {"percentDone":95};
stats['it_IT'] = {"percentDone":95};
stats['hu_HU'] = {"percentDone":83};
stats['nl_BE'] = {"percentDone":86};
stats['nl_NL'] = {"percentDone":90};
stats['nb_NO'] = {"percentDone":96};
stats['fa'] = {"percentDone":67};
stats['pl_PL'] = {"percentDone":89};
stats['es_ES'] = {"percentDone":96};
stats['eo'] = {"percentDone":30};
stats['fi_FI'] = {"percentDone":95};
stats['fr_FR'] = {"percentDone":96};
stats['gl_ES'] = {"percentDone":35};
stats['id_ID'] = {"percentDone":94};
stats['it_IT'] = {"percentDone":92};
stats['hu_HU'] = {"percentDone":80};
stats['nl_BE'] = {"percentDone":83};
stats['nl_NL'] = {"percentDone":87};
stats['nb_NO'] = {"percentDone":93};
stats['fa'] = {"percentDone":65};
stats['pl_PL'] = {"percentDone":86};
stats['pt_BR'] = {"percentDone":96};
stats['pt_PT'] = {"percentDone":89};
stats['ro'] = {"percentDone":62};
stats['sl_SI'] = {"percentDone":90};
stats['sv'] = {"percentDone":99};
stats['th_TH'] = {"percentDone":42};
stats['vi'] = {"percentDone":96};
stats['tr_TR'] = {"percentDone":99};
stats['uk_UA'] = {"percentDone":89};
stats['el_GR'] = {"percentDone":92};
stats['ru_RU'] = {"percentDone":99};
stats['sr_RS'] = {"percentDone":80};
stats['zh_CN'] = {"percentDone":100};
stats['zh_TW'] = {"percentDone":95};
stats['ja_JP'] = {"percentDone":100};
stats['ko'] = {"percentDone":94};
stats['pt_PT'] = {"percentDone":86};
stats['ro'] = {"percentDone":60};
stats['sl_SI'] = {"percentDone":96};
stats['sv'] = {"percentDone":95};
stats['th_TH'] = {"percentDone":43};
stats['vi'] = {"percentDone":93};
stats['tr_TR'] = {"percentDone":95};
stats['uk_UA'] = {"percentDone":85};
stats['el_GR'] = {"percentDone":89};
stats['ru_RU'] = {"percentDone":95};
stats['sr_RS'] = {"percentDone":78};
stats['zh_CN'] = {"percentDone":96};
stats['zh_TW'] = {"percentDone":92};
stats['ja_JP'] = {"percentDone":96};
stats['ko'] = {"percentDone":91};
module.exports = { locales: locales, stats: stats };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,15 @@
/* eslint-disable no-unused-vars */
const fs = require('fs-extra');
const { setupDatabaseAndSynchronizer, switchClient, exportDir, supportDir } = require('../../testing/test-utils.js');
const InteropService_Exporter_Md = require('../../services/interop/InteropService_Exporter_Md').default;
const BaseModel = require('../../BaseModel').default;
const Folder = require('../../models/Folder').default;
const Resource = require('../../models/Resource').default;
const Note = require('../../models/Note').default;
const shim = require('../../shim').default;
const { MarkupToHtml } = require('@joplin/renderer');
import * as fs from 'fs-extra';
import { setupDatabaseAndSynchronizer, switchClient, exportDir, supportDir } from '../../testing/test-utils.js';
import InteropService_Exporter_Md from '../../services/interop/InteropService_Exporter_Md';
import BaseModel from '../../BaseModel';
import Folder from '../../models/Folder';
import Resource from '../../models/Resource';
import Note from '../../models/Note';
import shim from '../../shim';
import { MarkupToHtml } from '@joplin/renderer';
import { NoteEntity, ResourceEntity } from '../database/types.js';
import InteropService from './InteropService.js';
import { fileExtension } from '../../path-utils.js';
describe('interop/InteropService_Exporter_Md', function() {
@@ -33,8 +33,8 @@ describe('interop/InteropService_Exporter_Md', function() {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -59,13 +59,13 @@ describe('interop/InteropService_Exporter_Md', function() {
queueExportItem(BaseModel.TYPE_NOTE, note3);
queueExportItem(BaseModel.TYPE_RESOURCE, (await Note.linkedResourceIds(note3.body))[0]);
expect(!exporter.context() && !(exporter.context().notePaths || Object.keys(exporter.context().notePaths).length)).toBe(false, 'Context should be empty before processing.');
expect(!exporter.context() && !(exporter.context().notePaths || Object.keys(exporter.context().notePaths).length)).toBe(false);
await exporter.processItem(Folder.modelType(), folder1);
await exporter.processItem(Folder.modelType(), folder2);
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
expect(Object.keys(exporter.context().notePaths).length).toBe(3, 'There should be 3 note paths in the context.');
expect(Object.keys(exporter.context().notePaths).length).toBe(3);
expect(exporter.context().notePaths[note1.id]).toBe('folder1/note1.md');
expect(exporter.context().notePaths[note2.id]).toBe('folder1/note2.md');
expect(exporter.context().notePaths[note3.id]).toBe('folder2/note3.html');
@@ -75,8 +75,8 @@ describe('interop/InteropService_Exporter_Md', function() {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -110,9 +110,9 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processResource(resource1, Resource.fullPath(resource1));
await exporter.processResource(resource2, Resource.fullPath(resource2));
expect(!exporter.context() && !(exporter.context().destResourcePaths || Object.keys(exporter.context().destResourcePaths).length)).toBe(false, 'Context should be empty before processing.');
expect(!exporter.context() && !(exporter.context().destResourcePaths || Object.keys(exporter.context().destResourcePaths).length)).toBe(false);
expect(Object.keys(exporter.context().destResourcePaths).length).toBe(2, 'There should be 2 resource paths in the context.');
expect(Object.keys(exporter.context().destResourcePaths).length).toBe(2);
expect(exporter.context().destResourcePaths[resource1.id]).toBe(`${exportDir()}/_resources/photo.jpg`);
expect(exporter.context().destResourcePaths[resource2.id]).toBe(`${exportDir()}/_resources/photo-1.jpg`);
}));
@@ -121,8 +121,8 @@ describe('interop/InteropService_Exporter_Md', function() {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -139,7 +139,7 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processItem(Folder.modelType(), folder1);
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
expect(Object.keys(exporter.context().notePaths).length).toBe(2, 'There should be 2 note paths in the context.');
expect(Object.keys(exporter.context().notePaths).length).toBe(2);
expect(exporter.context().notePaths[note1.id]).toBe('folder1/note1.md');
expect(exporter.context().notePaths[note1_2.id]).toBe('folder1/note1-1.md');
}));
@@ -148,8 +148,8 @@ describe('interop/InteropService_Exporter_Md', function() {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -167,7 +167,7 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
expect(Object.keys(exporter.context().notePaths).length).toBe(1, 'There should be 1 note paths in the context.');
expect(Object.keys(exporter.context().notePaths).length).toBe(1);
expect(exporter.context().notePaths[note1.id]).toBe('folder1/note1-1.md');
}));
@@ -175,8 +175,8 @@ describe('interop/InteropService_Exporter_Md', function() {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -204,16 +204,16 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processResource(resource1, Resource.fullPath(resource1));
await exporter.processResource(resource2, Resource.fullPath(resource2));
expect(await shim.fsDriver().exists(`${exportDir()}/_resources/photo.jpg`)).toBe(true, 'Resource file should be copied to _resources directory.');
expect(await shim.fsDriver().exists(`${exportDir()}/_resources/photo-1.jpg`)).toBe(true, 'Resource file should be copied to _resources directory.');
expect(await shim.fsDriver().exists(`${exportDir()}/_resources/photo.jpg`)).toBe(true);
expect(await shim.fsDriver().exists(`${exportDir()}/_resources/photo-1.jpg`)).toBe(true);
}));
it('should create folders in fs', (async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -234,17 +234,17 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
await exporter.processItem(Note.modelType(), note2);
expect(await shim.fsDriver().exists(`${exportDir()}/folder1`)).toBe(true, 'Folder should be created in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/folder1/folder2`)).toBe(true, 'Folder should be created in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/folder1/folder3`)).toBe(true, 'Folder should be created in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/folder1`)).toBe(true);
expect(await shim.fsDriver().exists(`${exportDir()}/folder1/folder2`)).toBe(true);
expect(await shim.fsDriver().exists(`${exportDir()}/folder1/folder3`)).toBe(true);
}));
it('should save notes in fs', (async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -271,17 +271,17 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processItem(Note.modelType(), note2);
await exporter.processItem(Note.modelType(), note3);
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note1.id]}`)).toBe(true, 'File should be saved in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note2.id]}`)).toBe(true, 'File should be saved in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note3.id]}`)).toBe(true, 'File should be saved in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note1.id]}`)).toBe(true);
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note2.id]}`)).toBe(true);
expect(await shim.fsDriver().exists(`${exportDir()}/${exporter.context().notePaths[note3.id]}`)).toBe(true);
}));
it('should replace resource ids with relative paths', (async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -325,7 +325,7 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processResource(resource2, Resource.fullPath(resource2));
await exporter.processResource(resource3, Resource.fullPath(resource3));
await exporter.processResource(resource4, Resource.fullPath(resource3));
const context = {
const context: any = {
resourcePaths: {},
};
context.resourcePaths[resource1.id] = 'resource1.jpg';
@@ -343,25 +343,25 @@ describe('interop/InteropService_Exporter_Md', function() {
const note3_body = await shim.fsDriver().readFile(`${exportDir()}/${exporter.context().notePaths[note3.id]}`);
const note4_body = await shim.fsDriver().readFile(`${exportDir()}/${exporter.context().notePaths[note4.id]}`);
expect(note1_body).toContain('](../_resources/photo.jpg)', 'Resource id should be replaced with a relative path.');
expect(note2_body).toContain('](../../_resources/photo-1.jpg)', 'Resource id should be replaced with a relative path.');
expect(note3_body).toContain('<img src="../../_resources/photo-2.jpg" alt="alt">', 'Resource id should be replaced with a relative path.');
expect(note4_body).toContain('](../../_resources/photo-3.jpg "title")', 'Resource id should be replaced with a relative path.');
expect(note1_body).toContain('](../_resources/photo.jpg)');
expect(note2_body).toContain('](../../_resources/photo-1.jpg)');
expect(note3_body).toContain('<img src="../../_resources/photo-2.jpg" alt="alt">');
expect(note4_body).toContain('](../../_resources/photo-3.jpg "title")');
}));
it('should replace note ids with relative paths', (async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
});
};
const changeNoteBodyAndReload = async (note, newBody) => {
const changeNoteBodyAndReload = async (note: NoteEntity, newBody: string) => {
note.body = newBody;
await Note.save(note);
return await Note.load(note.id);
@@ -395,18 +395,18 @@ describe('interop/InteropService_Exporter_Md', function() {
const note2_body = await shim.fsDriver().readFile(`${exportDir()}/${exporter.context().notePaths[note2.id]}`);
const note3_body = await shim.fsDriver().readFile(`${exportDir()}/${exporter.context().notePaths[note3.id]}`);
expect(note1_body).toContain('](../folder3/note3.md)', 'Note id should be replaced with a relative path.');
expect(note2_body).toContain('](../../folder3/note3.md)', 'Resource id should be replaced with a relative path.');
expect(note2_body).toContain('](../../folder1/note1.md)', 'Resource id should be replaced with a relative path.');
expect(note3_body).toContain('](../folder1/folder2/note2.md)', 'Resource id should be replaced with a relative path.');
expect(note1_body).toContain('](../folder3/note3.md)');
expect(note2_body).toContain('](../../folder3/note3.md)');
expect(note2_body).toContain('](../../folder1/note1.md)');
expect(note3_body).toContain('](../folder1/folder2/note2.md)');
}));
it('should url encode relative note links', (async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir());
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
const itemsToExport: any[] = [];
const queueExportItem = (itemType: number, itemOrId: any) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -425,6 +425,26 @@ describe('interop/InteropService_Exporter_Md', function() {
await exporter.processItem(Note.modelType(), note2);
const note2_body = await shim.fsDriver().readFile(`${exportDir()}/${exporter.context().notePaths[note2.id]}`);
expect(note2_body).toContain('[link](../folder%20with%20space1/note1%20name%20with%20space.md)', 'Whitespace in URL should be encoded');
expect(note2_body).toContain('[link](../folder%20with%20space1/note1%20name%20with%20space.md)');
}));
it('should preserve resource file extension', (async () => {
const folder = await Folder.save({ title: 'testing' });
const note = await Note.save({ title: 'mynote', parent_id: folder.id });
await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
const resource: ResourceEntity = (await Resource.all())[0];
await Resource.save({ id: resource.id, title: 'veryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitleveryverylongtitle.jpg' });
const service = InteropService.instance();
await service.export({
path: exportDir(),
format: 'md',
});
const resourceFilename = (await fs.readdir(`${exportDir()}/_resources`))[0];
expect(fileExtension(resourceFilename)).toBe('jpg');
}));
});

View File

@@ -143,7 +143,7 @@ export default class InteropService_Exporter_Md extends InteropService_Exporter_
if (resource.filename) {
fileName = resource.filename;
} else if (resource.title) {
fileName = friendlySafeFilename(resource.title);
fileName = friendlySafeFilename(resource.title, null, true);
}
// Fall back on the resource filename saved in the users resource folder

View File

@@ -122,6 +122,7 @@ describe('interop/InteropService_Exporter_Md_frontmatter', function() {
const content = await exportAndLoad(`${exportDir()}/folder1/Source_title.md`);
expect(content).toContain('title: |-\n Source\n title');
}));
test('should not export coordinates if they\'re not available', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'Coordinates', body: '**ma note**', parent_id: folder1.id });

View File

@@ -268,7 +268,7 @@ export default class ShareService {
if (!masterKey) throw new Error(`Cannot find master key with ID "${masterKeyId}"`);
const recipientPublicKey: PublicPrivateKeyPair = await this.userPublicKey(recipientEmail);
if (!recipientPublicKey) throw new Error(_('Cannot share notebook with recipient %s because they do not have a public key. Ask them to create one from the menu "%s"', recipientEmail, 'Tools > Generate Public-Private Key pair'));
if (!recipientPublicKey) throw new Error(_('Cannot share encrypted notebook with recipient %s because they have not enabled end-to-end encryption. They may do so from the screen Configuration > Encryption.', recipientEmail));
logger.info('Reencrypting master key with recipient public key', recipientPublicKey);

View File

@@ -19,8 +19,8 @@
"author": "",
"license": "MIT",
"dependencies": {
"@joplin/lib": "^2.6.2",
"@joplin/tools": "^2.6.2",
"@joplin/lib": "~2.6",
"@joplin/tools": "~2.6",
"fs-extra": "^9.0.1",
"gh-release-assets": "^2.0.0",
"node-fetch": "^2.6.1",

View File

@@ -12,6 +12,13 @@ describe('linkReplacement', () => {
expect(r).toBe('<a data-from-md href=\'https://example.com/test\'>');
});
test('should handle non-resource links with single quotes in it', () => {
// Handles a link such as:
// [Google](https://www.goo'onclick=javascript:alert(/1/);f=')
const r = linkReplacement('https://www.goo\'onclick=javascript:alert(/1/);f=\'', { linkRenderingType: 1 }).html;
expect(r).toBe('<a data-from-md href=\'https://www.goo&apos;onclick=javascript:alert(/1/);f=&apos;\' onclick=\'postMessage("https://www.goo%27onclick=javascript:alert(/1/);f=%27", { resourceId: "" }); return false;\'>');
});
test('should handle resource links - downloaded status', () => {
const resourceId = 'f6afba55bdf74568ac94f8d1e3578d2c';

View File

@@ -122,7 +122,7 @@ export default function(href: string, options: Options = null): LinkReplacementR
icon = '';
attrHtml.push(`href='${htmlentities(href)}'`);
} else {
attrHtml.push(`href='${hrefAttr}'`);
attrHtml.push(`href='${htmlentities(hrefAttr)}'`);
if (js) attrHtml.push(js);
}

View File

@@ -4,7 +4,9 @@ const nodeSqlite = require('sqlite3');
shimInit({ nodeSqlite });
// We don't want the tests to fail due to timeout, especially on CI, and certain
// tests can take more time since we do integration testing too.
jest.setTimeout(30 * 1000);
// tests can take more time since we do integration testing too. The share tests
// in particular can take a while.
jest.setTimeout(60 * 1000);
process.env.JOPLIN_IS_TESTING = '1';

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
{
"name": "@joplin/server",
"version": "2.6.2",
"version": "2.6.3",
"private": true,
"scripts": {
"start-dev": "npm run build && nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
"start-dev": "npm run build && JOPLIN_IS_TESTING=1 nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
"start-dev-no-watch": "node dist/app.js --env dev",
"rebuild": "npm run clean && npm run build && npm run tsc",
"build": "gulp build",
@@ -21,6 +21,7 @@
"watch": "tsc --watch --project tsconfig.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.40.0",
"@fortawesome/fontawesome-free": "^5.15.1",
"@joplin/lib": "~2.6",
"@joplin/renderer": "~2.6",

Binary file not shown.

View File

@@ -5,7 +5,7 @@ import * as Koa from 'koa';
import * as fs from 'fs-extra';
import Logger, { LoggerWrapper, TargetType } from '@joplin/lib/Logger';
import config, { initConfig, runningInDocker } from './config';
import { migrateLatest, waitForConnection, sqliteDefaultDir, latestMigration } from './db';
import { migrateLatest, waitForConnection, sqliteDefaultDir, latestMigration, DbConnection } from './db';
import { AppContext, Env, KoaNext } from './utils/types';
import FsDriverNode from '@joplin/lib/fs-driver-node';
import routeHandler from './middleware/routeHandler';
@@ -17,10 +17,11 @@ import startServices from './utils/startServices';
import { credentialFile } from './utils/testing/testUtils';
import apiVersionHandler from './middleware/apiVersionHandler';
import clickJackingHandler from './middleware/clickJackingHandler';
import newModelFactory from './models/factory';
import newModelFactory, { Options } from './models/factory';
import setupCommands from './utils/setupCommands';
import { RouteResponseFormat, routeResponseFormat } from './utils/routeUtils';
import { parseEnv } from './env';
import storageDriverFromConfig from './models/items/storage/storageDriverFromConfig';
interface Argv {
env?: Env;
@@ -61,6 +62,8 @@ function appLogger(): LoggerWrapper {
}
function markPasswords(o: Record<string, any>): Record<string, any> {
if (!o) return o;
const output: Record<string, any> = {};
for (const k of Object.keys(o)) {
@@ -219,6 +222,13 @@ async function main() {
fs.writeFileSync(pidFile, `${process.pid}`);
}
const newModelFactoryOptions = async (db: DbConnection): Promise<Options> => {
return {
storageDriver: await storageDriverFromConfig(config().storageDriver, db, { assignDriverId: env !== 'buildTypes' }),
storageDriverFallback: await storageDriverFromConfig(config().storageDriverFallback, db, { assignDriverId: env !== 'buildTypes' }),
};
};
let runCommandAndExitApp = true;
if (selectedCommand) {
@@ -235,7 +245,7 @@ async function main() {
});
} else {
const connectionCheck = await waitForConnection(config().database);
const models = newModelFactory(connectionCheck.connection, config());
const models = newModelFactory(connectionCheck.connection, config(), await newModelFactoryOptions(connectionCheck.connection));
await selectedCommand.run(commandArgv, {
db: connectionCheck.connection,
@@ -253,6 +263,8 @@ async function main() {
appLogger().info('Log dir:', config().logDir);
appLogger().info('DB Config:', markPasswords(config().database));
appLogger().info('Mailer Config:', markPasswords(config().mailer));
appLogger().info('Content driver:', markPasswords(config().storageDriver));
appLogger().info('Content driver (fallback):', markPasswords(config().storageDriverFallback));
appLogger().info('Trying to connect to database...');
const connectionCheck = await waitForConnection(config().database);
@@ -263,7 +275,8 @@ async function main() {
appLogger().info('Connection check:', connectionCheckLogInfo);
const ctx = app.context as AppContext;
await setupAppContext(ctx, env, connectionCheck.connection, appLogger);
await setupAppContext(ctx, env, connectionCheck.connection, appLogger, await newModelFactoryOptions(connectionCheck.connection));
await initializeJoplinUtils(config(), ctx.joplinBase.models, ctx.joplinBase.services.mustache);
if (config().database.autoMigration) {

View File

@@ -3,6 +3,7 @@ import { Config, DatabaseConfig, DatabaseConfigClient, Env, MailerConfig, RouteT
import * as pathUtils from 'path';
import { loadStripeConfig, StripePublicConfig } from '@joplin/lib/utils/joplinCloud';
import { EnvVariables } from './env';
import parseStorageDriverConnectionString from './models/items/storage/parseStorageDriverConnectionString';
interface PackageJson {
version: string;
@@ -130,6 +131,8 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
supportName: env.SUPPORT_NAME || appName,
businessEmail: env.BUSINESS_EMAIL || supportEmail,
cookieSecure: env.COOKIES_SECURE,
storageDriver: parseStorageDriverConnectionString(env.STORAGE_DRIVER),
storageDriverFallback: parseStorageDriverConnectionString(env.STORAGE_DRIVER_FALLBACK),
...overrides,
};
}

View File

@@ -1,70 +1,13 @@
export interface EnvVariables {
// The possible env variables and their defaults are listed below.
//
// The env variables can be of type string, integer or boolean. When the type is
// boolean, set the variable to "0" or "1" in your env file.
const defaultEnvValues: EnvVariables = {
// ==================================================
// General config
// ==================================================
APP_NAME: string;
APP_PORT: number;
SIGNUP_ENABLED: boolean;
TERMS_ENABLED: boolean;
ACCOUNT_TYPES_ENABLED: boolean;
ERROR_STACK_TRACES: boolean;
COOKIES_SECURE: boolean;
RUNNING_IN_DOCKER: boolean;
// ==================================================
// URL config
// ==================================================
APP_BASE_URL: string;
USER_CONTENT_BASE_URL: string;
API_BASE_URL: string;
JOPLINAPP_BASE_URL: string;
// ==================================================
// Database config
// ==================================================
DB_CLIENT: string;
DB_SLOW_QUERY_LOG_ENABLED: boolean;
DB_SLOW_QUERY_LOG_MIN_DURATION: number;
DB_AUTO_MIGRATION: boolean;
POSTGRES_PASSWORD: string;
POSTGRES_DATABASE: string;
POSTGRES_USER: string;
POSTGRES_HOST: string;
POSTGRES_PORT: number;
// This must be the full path to the database file
SQLITE_DATABASE: string;
// ==================================================
// Mailer config
// ==================================================
MAILER_ENABLED: boolean;
MAILER_HOST: string;
MAILER_PORT: number;
MAILER_SECURE: boolean;
MAILER_AUTH_USER: string;
MAILER_AUTH_PASSWORD: string;
MAILER_NOREPLY_NAME: string;
MAILER_NOREPLY_EMAIL: string;
SUPPORT_EMAIL: string;
SUPPORT_NAME: string;
BUSINESS_EMAIL: string;
// ==================================================
// Stripe config
// ==================================================
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
}
const defaultEnvValues: EnvVariables = {
APP_NAME: 'Joplin Server',
APP_PORT: 22300,
SIGNUP_ENABLED: false,
@@ -74,11 +17,19 @@ const defaultEnvValues: EnvVariables = {
COOKIES_SECURE: false,
RUNNING_IN_DOCKER: false,
// ==================================================
// URL config
// ==================================================
APP_BASE_URL: '',
USER_CONTENT_BASE_URL: '',
API_BASE_URL: '',
JOPLINAPP_BASE_URL: 'https://joplinapp.org',
// ==================================================
// Database config
// ==================================================
DB_CLIENT: 'sqlite3',
DB_SLOW_QUERY_LOG_ENABLED: false,
DB_SLOW_QUERY_LOG_MIN_DURATION: 1000,
@@ -90,8 +41,20 @@ const defaultEnvValues: EnvVariables = {
POSTGRES_HOST: '',
POSTGRES_PORT: 5432,
// This must be the full path to the database file
SQLITE_DATABASE: '',
// ==================================================
// Content driver config
// ==================================================
STORAGE_DRIVER: 'Type=Database',
STORAGE_DRIVER_FALLBACK: '',
// ==================================================
// Mailer config
// ==================================================
MAILER_ENABLED: false,
MAILER_HOST: '',
MAILER_PORT: 587,
@@ -105,10 +68,62 @@ const defaultEnvValues: EnvVariables = {
SUPPORT_NAME: '',
BUSINESS_EMAIL: '',
// ==================================================
// Stripe config
// ==================================================
STRIPE_SECRET_KEY: '',
STRIPE_WEBHOOK_SECRET: '',
};
export interface EnvVariables {
APP_NAME: string;
APP_PORT: number;
SIGNUP_ENABLED: boolean;
TERMS_ENABLED: boolean;
ACCOUNT_TYPES_ENABLED: boolean;
ERROR_STACK_TRACES: boolean;
COOKIES_SECURE: boolean;
RUNNING_IN_DOCKER: boolean;
APP_BASE_URL: string;
USER_CONTENT_BASE_URL: string;
API_BASE_URL: string;
JOPLINAPP_BASE_URL: string;
DB_CLIENT: string;
DB_SLOW_QUERY_LOG_ENABLED: boolean;
DB_SLOW_QUERY_LOG_MIN_DURATION: number;
DB_AUTO_MIGRATION: boolean;
POSTGRES_PASSWORD: string;
POSTGRES_DATABASE: string;
POSTGRES_USER: string;
POSTGRES_HOST: string;
POSTGRES_PORT: number;
SQLITE_DATABASE: string;
STORAGE_DRIVER: string;
STORAGE_DRIVER_FALLBACK: string;
MAILER_ENABLED: boolean;
MAILER_HOST: string;
MAILER_PORT: number;
MAILER_SECURE: boolean;
MAILER_AUTH_USER: string;
MAILER_AUTH_PASSWORD: string;
MAILER_NOREPLY_NAME: string;
MAILER_NOREPLY_EMAIL: string;
SUPPORT_EMAIL: string;
SUPPORT_NAME: string;
BUSINESS_EMAIL: string;
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
}
export function parseEnv(rawEnv: any, defaultOverrides: any = null): EnvVariables {
const output: EnvVariables = {
...defaultEnvValues,
@@ -125,7 +140,7 @@ export function parseEnv(rawEnv: any, defaultOverrides: any = null): EnvVariable
if (isNaN(v)) throw new Error(`Invalid number value for env variable ${key} = ${rawEnvValue}`);
(output as any)[key] = v;
} else if (typeof value === 'boolean') {
if (rawEnvValue !== '0' && rawEnvValue !== '1') throw new Error(`Invalid boolean for for env variable ${key}: ${rawEnvValue}`);
if (rawEnvValue !== '0' && rawEnvValue !== '1') throw new Error(`Invalid boolean value for env variable ${key}: ${rawEnvValue} (Should be either "0" or "1")`);
(output as any)[key] = rawEnvValue === '1';
} else if (typeof value === 'string') {
(output as any)[key] = `${rawEnvValue}`;

View File

@@ -2,6 +2,13 @@ import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models,
import { Notification, UserFlagType } from '../services/database/types';
import { defaultAdminEmail, defaultAdminPassword } from '../db';
import notificationHandler from './notificationHandler';
import { AppContext } from '../utils/types';
const runNotificationHandler = async (sessionId: string): Promise<AppContext> => {
const context = await koaAppContext({ sessionId: sessionId });
await notificationHandler(context, koaNext);
return context;
};
describe('notificationHandler', function() {
@@ -18,22 +25,25 @@ describe('notificationHandler', function() {
});
test('should check admin password', async function() {
const { session } = await createUserAndSession(1, true);
const r = await createUserAndSession(1, true);
const session = r.session;
let admin = r.user;
// The default admin password actually doesn't pass the complexity
// check, so we need to skip validation for testing here. Eventually, a
// better mechanism to set the initial default admin password should
// probably be implemented.
const admin = await models().user().save({
admin = await models().user().save({
id: admin.id,
email: defaultAdminEmail,
password: defaultAdminPassword,
is_admin: 1,
email_confirmed: 1,
}, { skipValidation: true });
{
const ctx = await koaAppContext({ sessionId: session.id });
await notificationHandler(ctx, koaNext);
const ctx = await runNotificationHandler(session.id);
const notifications: Notification[] = await models().notification().all();
expect(notifications.length).toBe(1);
@@ -49,8 +59,7 @@ describe('notificationHandler', function() {
password: 'changed!',
}, { skipValidation: true });
const ctx = await koaAppContext({ sessionId: session.id });
await notificationHandler(ctx, koaNext);
const ctx = await runNotificationHandler(session.id);
const notifications: Notification[] = await models().notification().all();
expect(notifications.length).toBe(1);
@@ -69,8 +78,7 @@ describe('notificationHandler', function() {
password: defaultAdminPassword,
});
const context = await koaAppContext({ sessionId: session.id });
await notificationHandler(context, koaNext);
await runNotificationHandler(session.id);
const notifications: Notification[] = await models().notification().all();
expect(notifications.length).toBe(0);
@@ -81,10 +89,24 @@ describe('notificationHandler', function() {
await models().userFlag().add(user.id, UserFlagType.FailedPaymentFinal);
const ctx = await koaAppContext({ sessionId: session.id });
await notificationHandler(ctx, koaNext);
const ctx = await runNotificationHandler(session.id);
expect(ctx.joplin.notifications.find(v => v.id === 'accountDisabled')).toBeTruthy();
});
test('should display a banner if the email is not confirmed', async function() {
const { session, user } = await createUserAndSession(1);
{
const ctx = await runNotificationHandler(session.id);
expect(ctx.joplin.notifications.find(v => v.id === 'confirmEmail')).toBeTruthy();
}
{
await models().user().save({ id: user.id, email_confirmed: 1 });
const ctx = await runNotificationHandler(session.id);
expect(ctx.joplin.notifications.find(v => v.id === 'confirmEmail')).toBeFalsy();
}
});
});

View File

@@ -25,7 +25,7 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
_('The default admin password is insecure and has not been changed! [Change it now](%s)', profileUrl())
);
} else {
await notificationModel.markAsRead(ctx.joplin.owner.id, NotificationKey.ChangeAdminPassword);
await notificationModel.setRead(ctx.joplin.owner.id, NotificationKey.ChangeAdminPassword);
}
}
@@ -57,6 +57,22 @@ async function handleUserFlags(ctx: AppContext): Promise<NotificationView> {
return null;
}
async function handleConfirmEmailNotification(ctx: AppContext): Promise<NotificationView> {
if (!ctx.joplin.owner) return null;
if (!ctx.joplin.owner.email_confirmed) {
return {
id: 'confirmEmail',
messageHtml: renderMarkdown('An email has been sent to you containing an activation link to complete your registration.\n\nMake sure you click it to secure your account and keep access to it.'),
levelClassName: levelClassName(NotificationLevel.Important),
closeUrl: '',
};
}
return null;
}
// async function handleSqliteInProdNotification(ctx: AppContext) {
// if (!ctx.joplin.owner.is_admin) return;
@@ -104,11 +120,18 @@ export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
if (!ctx.joplin.owner) return next();
await handleChangeAdminPasswordNotification(ctx);
await handleConfirmEmailNotification(ctx);
// await handleSqliteInProdNotification(ctx);
const notificationViews = await makeNotificationViews(ctx);
const userFlagView = await handleUserFlags(ctx);
if (userFlagView) notificationViews.push(userFlagView);
const nonDismisableViews = [
await handleUserFlags(ctx),
await handleConfirmEmailNotification(ctx),
];
for (const nonDismisableView of nonDismisableViews) {
if (nonDismisableView) notificationViews.push(nonDismisableView);
}
ctx.joplin.notifications = notificationViews;
} catch (error) {

View File

@@ -73,6 +73,6 @@ export default async function(ctx: AppContext) {
// Technically this is not the total request duration because there are
// other middlewares but that should give a good approximation
const requestDuration = Date.now() - requestStartTime;
ctx.joplin.appLogger().info(`${ctx.request.method} ${ctx.path} (${requestDuration}ms)`);
ctx.joplin.appLogger().info(`${ctx.request.method} ${ctx.path} (${ctx.response.status}) (${requestDuration}ms)`);
}
}

View File

@@ -0,0 +1,32 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('storages', (table: Knex.CreateTableBuilder) => {
table.increments('id').unique().primary().notNullable();
table.text('connection_string').notNullable();
});
await db('storages').insert({
connection_string: 'Type=Database',
});
// First we create the column and set a default so as to populate the
// content_storage_id field.
await db.schema.alterTable('items', (table: Knex.CreateTableBuilder) => {
table.integer('content_storage_id').defaultTo(1).notNullable();
});
// Once it's set, we remove the default as that should be explicitly set.
await db.schema.alterTable('items', (table: Knex.CreateTableBuilder) => {
table.integer('content_storage_id').notNullable().alter();
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('storages');
await db.schema.alterTable('items', (table: Knex.CreateTableBuilder) => {
table.dropColumn('content_storage_id');
});
}

View File

@@ -3,7 +3,7 @@ import { DbConnection } from '../db';
import TransactionHandler from '../utils/TransactionHandler';
import uuidgen from '../utils/uuidgen';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
import { Models } from './factory';
import { Models, NewModelFactoryHandler } from './factory';
import * as EventEmitter from 'events';
import { Config } from '../utils/types';
import personalizedUserContentBaseUrl from '@joplin/lib/services/joplinServer/personalizedUserContentBaseUrl';
@@ -54,12 +54,12 @@ export default abstract class BaseModel<T> {
private defaultFields_: string[] = [];
private db_: DbConnection;
private transactionHandler_: TransactionHandler;
private modelFactory_: Function;
private modelFactory_: NewModelFactoryHandler;
private static eventEmitter_: EventEmitter = null;
private config_: Config;
private savePoints_: SavePoint[] = [];
public constructor(db: DbConnection, modelFactory: Function, config: Config) {
public constructor(db: DbConnection, modelFactory: NewModelFactoryHandler, config: Config) {
this.db_ = db;
this.modelFactory_ = modelFactory;
this.config_ = config;
@@ -71,7 +71,7 @@ export default abstract class BaseModel<T> {
// connection is passed to it. That connection can be the regular db
// connection, or the active transaction.
protected models(db: DbConnection = null): Models {
return this.modelFactory_(db || this.db, this.config_);
return this.modelFactory_(db || this.db);
}
protected get baseUrl(): string {
@@ -90,7 +90,7 @@ export default abstract class BaseModel<T> {
return this.config_.appName;
}
protected get db(): DbConnection {
public get db(): DbConnection {
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
return this.db_;
}

View File

@@ -38,12 +38,12 @@ describe('ChangeModel', function() {
const changeModel = models().change();
await msleep(1); const item1 = await models().item().makeTestItem(user.id, 1); // [1] CREATE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001A.md' }); // [2] UPDATE 1a
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001B.md' }); // [3] UPDATE 1b
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001A.md', content: Buffer.from('') }); // [2] UPDATE 1a
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: '0000000000000000000000000000001B.md', content: Buffer.from('') }); // [3] UPDATE 1b
await msleep(1); const item2 = await models().item().makeTestItem(user.id, 2); // [4] CREATE 2
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002A.md' }); // [5] UPDATE 2a
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002A.md', content: Buffer.from('') }); // [5] UPDATE 2a
await msleep(1); await itemModel.delete(item1.id); // [6] DELETE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002B.md' }); // [7] UPDATE 2b
await msleep(1); await itemModel.saveForUser(user.id, { id: item2.id, name: '0000000000000000000000000000002B.md', content: Buffer.from('') }); // [7] UPDATE 2b
await msleep(1); const item3 = await models().item().makeTestItem(user.id, 3); // [8] CREATE 3
// Check that the 8 changes were created
@@ -120,7 +120,7 @@ describe('ChangeModel', function() {
let i = 1;
await msleep(1); const item1 = await models().item().makeTestItem(user.id, 1); // CREATE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: `test_mod${i++}` }); // UPDATE 1
await msleep(1); await itemModel.saveForUser(user.id, { id: item1.id, name: `test_mod${i++}`, content: Buffer.from('') }); // UPDATE 1
await expectThrow(async () => changeModel.delta(user.id, { limit: 1, cursor: 'invalid' }), 'resyncRequired');
});

View File

@@ -7,6 +7,10 @@ import { ApiError, ErrorForbidden, ErrorUnprocessableEntity } from '../utils/err
import { Knex } from 'knex';
import { ChangePreviousItem } from './ChangeModel';
import { unique } from '../utils/array';
import StorageDriverBase, { Context } from './items/storage/StorageDriverBase';
import { DbConnection } from '../db';
import { Config, StorageDriverMode } from '../utils/types';
import { NewModelFactoryHandler, Options } from './factory';
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
@@ -38,9 +42,22 @@ export interface ItemSaveOption extends SaveOptions {
shareId?: Uuid;
}
export interface ItemLoadOptions extends LoadOptions {
withContent?: boolean;
}
export default class ItemModel extends BaseModel<Item> {
private updatingTotalSizes_: boolean = false;
private storageDriver_: StorageDriverBase = null;
private storageDriverFallback_: StorageDriverBase = null;
public constructor(db: DbConnection, modelFactory: NewModelFactoryHandler, config: Config, options: Options) {
super(db, modelFactory, config);
this.storageDriver_ = options.storageDriver;
this.storageDriverFallback_ = options.storageDriverFallback;
}
protected get tableName(): string {
return 'items';
@@ -106,62 +123,106 @@ export default class ItemModel extends BaseModel<Item> {
return path.replace(extractNameRegex, '$1');
}
public byShareIdQuery(shareId: Uuid, options: LoadOptions = {}): Knex.QueryBuilder {
public byShareIdQuery(shareId: Uuid, options: ItemLoadOptions = {}): Knex.QueryBuilder {
return this
.db('items')
.select(this.selectFields(options, null, 'items'))
.where('jop_share_id', '=', shareId);
}
public async byShareId(shareId: Uuid, options: LoadOptions = {}): Promise<Item[]> {
public async byShareId(shareId: Uuid, options: ItemLoadOptions = {}): Promise<Item[]> {
const query = this.byShareIdQuery(shareId, options);
return await query;
}
public async loadByJopIds(userId: Uuid | Uuid[], jopIds: string[], options: LoadOptions = {}): Promise<Item[]> {
private async storageDriverWrite(itemId: Uuid, content: Buffer, context: Context) {
await this.storageDriver_.write(itemId, content, context);
if (this.storageDriverFallback_) {
if (this.storageDriverFallback_.mode === StorageDriverMode.ReadWrite) {
await this.storageDriverFallback_.write(itemId, content, context);
} else if (this.storageDriverFallback_.mode === StorageDriverMode.ReadOnly) {
await this.storageDriverFallback_.write(itemId, Buffer.from(''), context);
} else {
throw new Error(`Unsupported fallback mode: ${this.storageDriverFallback_.mode}`);
}
}
}
private async storageDriverRead(itemId: Uuid, context: Context) {
if (await this.storageDriver_.exists(itemId, context)) {
return this.storageDriver_.read(itemId, context);
} else {
if (!this.storageDriverFallback_) throw new Error(`Content does not exist but fallback content driver is not defined: ${itemId}`);
return this.storageDriverFallback_.read(itemId, context);
}
}
public async loadByJopIds(userId: Uuid | Uuid[], jopIds: string[], options: ItemLoadOptions = {}): Promise<Item[]> {
if (!jopIds.length) return [];
const userIds = Array.isArray(userId) ? userId : [userId];
if (!userIds.length) return [];
return this
const rows: Item[] = await this
.db('user_items')
.leftJoin('items', 'items.id', 'user_items.item_id')
.distinct(this.selectFields(options, null, 'items'))
.whereIn('user_items.user_id', userIds)
.whereIn('jop_id', jopIds);
if (options.withContent) {
for (const row of rows) {
row.content = await this.storageDriverRead(row.id, { models: this.models() });
}
}
return rows;
}
public async loadByJopId(userId: Uuid, jopId: string, options: LoadOptions = {}): Promise<Item> {
public async loadByJopId(userId: Uuid, jopId: string, options: ItemLoadOptions = {}): Promise<Item> {
const items = await this.loadByJopIds(userId, [jopId], options);
return items.length ? items[0] : null;
}
public async loadByNames(userId: Uuid | Uuid[], names: string[], options: LoadOptions = {}): Promise<Item[]> {
public async loadByNames(userId: Uuid | Uuid[], names: string[], options: ItemLoadOptions = {}): Promise<Item[]> {
if (!names.length) return [];
const userIds = Array.isArray(userId) ? userId : [userId];
return this
const rows: Item[] = await this
.db('user_items')
.leftJoin('items', 'items.id', 'user_items.item_id')
.distinct(this.selectFields(options, null, 'items'))
.whereIn('user_items.user_id', userIds)
.whereIn('name', names);
if (options.withContent) {
for (const row of rows) {
row.content = await this.storageDriverRead(row.id, { models: this.models() });
}
}
return rows;
}
public async loadByName(userId: Uuid, name: string, options: LoadOptions = {}): Promise<Item> {
public async loadByName(userId: Uuid, name: string, options: ItemLoadOptions = {}): Promise<Item> {
const items = await this.loadByNames(userId, [name], options);
return items.length ? items[0] : null;
}
public async loadWithContent(id: Uuid, options: LoadOptions = {}): Promise<Item> {
return this
.db('user_items')
.leftJoin('items', 'items.id', 'user_items.item_id')
.select(this.selectFields(options, ['*'], 'items'))
.where('items.id', '=', id)
.first();
public async loadWithContent(id: Uuid, options: ItemLoadOptions = {}): Promise<Item> {
const content = await this.storageDriverRead(id, { models: this.models() });
return {
...await this
.db('user_items')
.leftJoin('items', 'items.id', 'user_items.item_id')
.select(this.selectFields(options, ['*'], 'items'))
.where('items.id', '=', id)
.first(),
content,
};
}
public async loadAsSerializedJoplinItem(id: Uuid): Promise<string> {
@@ -255,9 +316,11 @@ export default class ItemModel extends BaseModel<Item> {
return this.itemToJoplinItem(raw);
}
public async saveFromRawContent(user: User, rawContentItems: SaveFromRawContentItem[], options: ItemSaveOption = null): Promise<SaveFromRawContentResult> {
public async saveFromRawContent(user: User, rawContentItems: SaveFromRawContentItem[] | SaveFromRawContentItem, options: ItemSaveOption = null): Promise<SaveFromRawContentResult> {
options = options || {};
if (!Array.isArray(rawContentItems)) rawContentItems = [rawContentItems];
// In this function, first we process the input items, which may be
// serialized Joplin items or actual buffers (for resources) and convert
// them to database items. Once it's done those db items are saved in
@@ -349,11 +412,46 @@ export default class ItemModel extends BaseModel<Item> {
continue;
}
const itemToSave = o.item;
const itemToSave = { ...o.item };
try {
const content = itemToSave.content;
delete itemToSave.content;
itemToSave.content_storage_id = this.storageDriver_.storageId;
itemToSave.content_size = content ? content.byteLength : 0;
// Here we save the item row and content, and we want to
// make sure that either both are saved or none of them.
// This is done by setting up a save point before saving the
// row, and rollbacking if the content cannot be saved.
//
// Normally, since we are in a transaction, throwing an
// error should work, but since we catch all errors within
// this block it doesn't work.
// TODO: When an item is uploaded multiple times
// simultaneously there could be a race condition, where the
// content would not match the db row (for example, the
// content_size would differ).
//
// Possible solutions:
//
// - Row-level lock on items.id, and release once the
// content is saved.
// - Or external lock - eg. Redis.
const savePoint = await this.setSavePoint();
const savedItem = await this.saveForUser(user.id, itemToSave);
try {
await this.storageDriverWrite(savedItem.id, content, { models: this.models() });
await this.releaseSavePoint(savePoint);
} catch (error) {
await this.rollbackSavePoint(savePoint);
throw error;
}
if (o.isNote) {
await this.models().itemResource().deleteByItemId(savedItem.id);
await this.models().itemResource().addResourceIds(savedItem.id, o.resourceIds);
@@ -390,7 +488,7 @@ export default class ItemModel extends BaseModel<Item> {
}
private childrenQuery(userId: Uuid, pathQuery: string = '', count: boolean = false, options: LoadOptions = {}): Knex.QueryBuilder {
private childrenQuery(userId: Uuid, pathQuery: string = '', count: boolean = false, options: ItemLoadOptions = {}): Knex.QueryBuilder {
const query = this
.db('user_items')
.innerJoin('items', 'user_items.item_id', 'items.id')
@@ -420,7 +518,7 @@ export default class ItemModel extends BaseModel<Item> {
return `${this.baseUrl}/items/${itemId}/content`;
}
public async children(userId: Uuid, pathQuery: string = '', pagination: Pagination = null, options: LoadOptions = {}): Promise<PaginatedItems> {
public async children(userId: Uuid, pathQuery: string = '', pagination: Pagination = null, options: ItemLoadOptions = {}): Promise<PaginatedItems> {
pagination = pagination || defaultPagination();
const query = this.childrenQuery(userId, pathQuery, false, options);
return paginateDbQuery(query, pagination, 'items');
@@ -532,6 +630,8 @@ export default class ItemModel extends BaseModel<Item> {
await this.models().share().delete(shares.map(s => s.id));
await this.models().userItem().deleteByItemIds(ids);
await this.models().itemResource().deleteByItemIds(ids);
await this.storageDriver_.delete(ids, { models: this.models() });
if (this.storageDriverFallback_) await this.storageDriverFallback_.delete(ids, { models: this.models() });
await super.delete(ids, options);
}, 'ItemModel::delete');
@@ -552,6 +652,7 @@ export default class ItemModel extends BaseModel<Item> {
public async makeTestItem(userId: Uuid, num: number) {
return this.saveForUser(userId, {
name: `${num.toString().padStart(32, '0')}.md`,
content: Buffer.from(''),
});
}
@@ -560,23 +661,27 @@ export default class ItemModel extends BaseModel<Item> {
for (let i = 1; i <= count; i++) {
await this.saveForUser(userId, {
name: `${i.toString().padStart(32, '0')}.md`,
content: Buffer.from(''),
});
}
}, 'ItemModel::makeTestItems');
}
// This method should be private because items should only be saved using
// saveFromRawContent, which is going to deal with the content driver. But
// since it's used in various test units, it's kept public for now.
public async saveForUser(userId: Uuid, item: Item, options: SaveOptions = {}): Promise<Item> {
if (!userId) throw new Error('userId is required');
item = { ... item };
const isNew = await this.isNew(item, options);
if (item.content) {
item.content_size = item.content.byteLength;
}
let previousItem: ChangePreviousItem = null;
if (item.content && !item.content_storage_id) {
item.content_storage_id = this.storageDriver_.storageId;
}
if (isNew) {
if (!item.mime_type) item.mime_type = mimeUtils.fromFilename(item.name) || '';
if (!item.owner_id) item.owner_id = userId;

View File

@@ -17,15 +17,15 @@ describe('NotificationModel', function() {
});
test('should require a user to create the notification', async function() {
await expectThrow(async () => models().notification().add('', NotificationKey.ConfirmEmail, NotificationLevel.Normal, NotificationKey.ConfirmEmail));
await expectThrow(async () => models().notification().add('', NotificationKey.EmailConfirmed, NotificationLevel.Normal, NotificationKey.EmailConfirmed));
});
test('should create a notification', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification();
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
const n: Notification = await model.loadByKey(user.id, NotificationKey.ConfirmEmail);
expect(n.key).toBe(NotificationKey.ConfirmEmail);
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Important, 'testing');
const n: Notification = await model.loadByKey(user.id, NotificationKey.EmailConfirmed);
expect(n.key).toBe(NotificationKey.EmailConfirmed);
expect(n.message).toBe('testing');
expect(n.level).toBe(NotificationLevel.Important);
});
@@ -33,18 +33,18 @@ describe('NotificationModel', function() {
test('should create only one notification per key', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification();
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Important, 'testing');
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Important, 'testing');
expect((await model.all()).length).toBe(1);
});
test('should mark a notification as read', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification();
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
expect((await model.loadByKey(user.id, NotificationKey.ConfirmEmail)).read).toBe(0);
await model.markAsRead(user.id, NotificationKey.ConfirmEmail);
expect((await model.loadByKey(user.id, NotificationKey.ConfirmEmail)).read).toBe(1);
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Important, 'testing');
expect((await model.loadByKey(user.id, NotificationKey.EmailConfirmed)).read).toBe(0);
await model.setRead(user.id, NotificationKey.EmailConfirmed);
expect((await model.loadByKey(user.id, NotificationKey.EmailConfirmed)).read).toBe(1);
});
});

View File

@@ -5,7 +5,7 @@ import BaseModel, { ValidateOptions } from './BaseModel';
export enum NotificationKey {
Any = 'any',
ConfirmEmail = 'confirmEmail',
// ConfirmEmail = 'confirmEmail',
PasswordSet = 'passwordSet',
EmailConfirmed = 'emailConfirmed',
ChangeAdminPassword = 'change_admin_password',
@@ -31,10 +31,10 @@ export default class NotificationModel extends BaseModel<Notification> {
public async add(userId: Uuid, key: NotificationKey, level: NotificationLevel = null, message: string = null): Promise<Notification> {
const notificationTypes: Record<string, NotificationType> = {
[NotificationKey.ConfirmEmail]: {
level: NotificationLevel.Normal,
message: `Welcome to ${this.appName}! An email has been sent to you containing an activation link to complete your registration.`,
},
// [NotificationKey.ConfirmEmail]: {
// level: NotificationLevel.Normal,
// message: `Welcome to ${this.appName}! An email has been sent to you containing an activation link to complete your registration. Make sure you click it to secure your account and keep access to it.`,
// },
[NotificationKey.EmailConfirmed]: {
level: NotificationLevel.Normal,
message: 'Your email has been confirmed',
@@ -83,12 +83,12 @@ export default class NotificationModel extends BaseModel<Notification> {
return this.save({ key: actualKey, message, level, owner_id: userId });
}
public async markAsRead(userId: Uuid, key: NotificationKey): Promise<void> {
public async setRead(userId: Uuid, key: NotificationKey, read: boolean = true): Promise<void> {
const n = await this.loadByKey(userId, key);
if (!n) return;
await this.db(this.tableName)
.update({ read: 1 })
.update({ read: read ? 1 : 0 })
.where('key', '=', key)
.andWhere('owner_id', '=', userId);
}

View File

@@ -0,0 +1,18 @@
import { Storage } from '../services/database/types';
import BaseModel from './BaseModel';
export default class StorageModel extends BaseModel<Storage> {
public get tableName(): string {
return 'storages';
}
protected hasUuid(): boolean {
return false;
}
public async byConnectionString(connectionString: string): Promise<Storage> {
return this.db(this.tableName).where('connection_string', connectionString).first();
}
}

View File

@@ -134,7 +134,7 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
account_type: accountType,
email,
full_name: fullName,
email_confirmed: 1,
email_confirmed: 0, // Email is not confirmed, because Stripe doesn't check this
password: uuidgen(),
must_set_password: 1,
});

View File

@@ -360,18 +360,18 @@ describe('UserModel', function() {
const syncInfo3: any = JSON.parse(JSON.stringify(syncInfo1));
delete syncInfo3.ppk;
await models().item().saveForUser(user1.id, {
content: Buffer.from(JSON.stringify(syncInfo1)),
await models().item().saveFromRawContent(user1, {
body: Buffer.from(JSON.stringify(syncInfo1)),
name: 'info.json',
});
await models().item().saveForUser(user2.id, {
content: Buffer.from(JSON.stringify(syncInfo2)),
await models().item().saveFromRawContent(user2, {
body: Buffer.from(JSON.stringify(syncInfo2)),
name: 'info.json',
});
await models().item().saveForUser(user3.id, {
content: Buffer.from(JSON.stringify(syncInfo3)),
await models().item().saveFromRawContent(user3, {
body: Buffer.from(JSON.stringify(syncInfo3)),
name: 'info.json',
});

View File

@@ -593,7 +593,7 @@ export default class UserModel extends BaseModel<User> {
public async publicPrivateKey(userId: string): Promise<PublicPrivateKeyPair> {
const syncInfo = await this.syncInfo(userId);
return syncInfo.ppk?.value || null;// syncInfo.ppk?.value.publicKey || '';
return syncInfo.ppk?.value || null;
}
// Note that when the "password" property is provided, it is going to be

View File

@@ -72,88 +72,111 @@ import SubscriptionModel from './SubscriptionModel';
import UserFlagModel from './UserFlagModel';
import EventModel from './EventModel';
import { Config } from '../utils/types';
import StorageDriverBase from './items/storage/StorageDriverBase';
import LockModel from './LockModel';
import StorageModel from './StorageModel';
export interface Options {
storageDriver: StorageDriverBase;
storageDriverFallback?: StorageDriverBase;
}
export type NewModelFactoryHandler = (db: DbConnection)=> Models;
export class Models {
private db_: DbConnection;
private config_: Config;
private options_: Options;
public constructor(db: DbConnection, config: Config) {
public constructor(db: DbConnection, config: Config, options: Options) {
this.db_ = db;
this.config_ = config;
this.options_ = options;
// if (!options.storageDriver) throw new Error('StorageDriver is required');
this.newModelFactory = this.newModelFactory.bind(this);
}
private newModelFactory(db: DbConnection) {
return new Models(db, this.config_, this.options_);
}
public item() {
return new ItemModel(this.db_, newModelFactory, this.config_);
return new ItemModel(this.db_, this.newModelFactory, this.config_, this.options_);
}
public user() {
return new UserModel(this.db_, newModelFactory, this.config_);
return new UserModel(this.db_, this.newModelFactory, this.config_);
}
public email() {
return new EmailModel(this.db_, newModelFactory, this.config_);
return new EmailModel(this.db_, this.newModelFactory, this.config_);
}
public userItem() {
return new UserItemModel(this.db_, newModelFactory, this.config_);
return new UserItemModel(this.db_, this.newModelFactory, this.config_);
}
public token() {
return new TokenModel(this.db_, newModelFactory, this.config_);
return new TokenModel(this.db_, this.newModelFactory, this.config_);
}
public itemResource() {
return new ItemResourceModel(this.db_, newModelFactory, this.config_);
return new ItemResourceModel(this.db_, this.newModelFactory, this.config_);
}
public apiClient() {
return new ApiClientModel(this.db_, newModelFactory, this.config_);
return new ApiClientModel(this.db_, this.newModelFactory, this.config_);
}
public session() {
return new SessionModel(this.db_, newModelFactory, this.config_);
return new SessionModel(this.db_, this.newModelFactory, this.config_);
}
public change() {
return new ChangeModel(this.db_, newModelFactory, this.config_);
return new ChangeModel(this.db_, this.newModelFactory, this.config_);
}
public notification() {
return new NotificationModel(this.db_, newModelFactory, this.config_);
return new NotificationModel(this.db_, this.newModelFactory, this.config_);
}
public share() {
return new ShareModel(this.db_, newModelFactory, this.config_);
return new ShareModel(this.db_, this.newModelFactory, this.config_);
}
public shareUser() {
return new ShareUserModel(this.db_, newModelFactory, this.config_);
return new ShareUserModel(this.db_, this.newModelFactory, this.config_);
}
public keyValue() {
return new KeyValueModel(this.db_, newModelFactory, this.config_);
return new KeyValueModel(this.db_, this.newModelFactory, this.config_);
}
public subscription() {
return new SubscriptionModel(this.db_, newModelFactory, this.config_);
return new SubscriptionModel(this.db_, this.newModelFactory, this.config_);
}
public userFlag() {
return new UserFlagModel(this.db_, newModelFactory, this.config_);
return new UserFlagModel(this.db_, this.newModelFactory, this.config_);
}
public event() {
return new EventModel(this.db_, newModelFactory, this.config_);
return new EventModel(this.db_, this.newModelFactory, this.config_);
}
public lock() {
return new LockModel(this.db_, newModelFactory, this.config_);
return new LockModel(this.db_, this.newModelFactory, this.config_);
}
public storage() {
return new StorageModel(this.db_, this.newModelFactory, this.config_);
}
}
export default function newModelFactory(db: DbConnection, config: Config): Models {
return new Models(db, config);
export default function newModelFactory(db: DbConnection, config: Config, options: Options): Models {
return new Models(db, config, options);
}

View File

@@ -0,0 +1,42 @@
import { StorageDriverConfig, StorageDriverMode } from '../../../utils/types';
import { Models } from '../../factory';
// ItemModel passes the models object when calling any of the driver handler.
// This is so that if there's an active transaction, the driver can use that (as
// required for example by StorageDriverDatabase).
export interface Context {
models: Models;
}
export default class StorageDriverBase {
private storageId_: number;
private config_: StorageDriverConfig;
public constructor(storageId: number, config: StorageDriverConfig) {
this.storageId_ = storageId;
this.config_ = config;
}
public get storageId(): number {
return this.storageId_;
}
public get config(): StorageDriverConfig {
return this.config_;
}
public get mode(): StorageDriverMode {
return this.config.mode || StorageDriverMode.ReadOnly;
}
public async write(_itemId: string, _content: Buffer, _context: Context): Promise<void> { throw new Error('Not implemented'); }
public async read(_itemId: string, _context: Context): Promise<Buffer> { throw new Error('Not implemented'); }
public async delete(_itemId: string | string[], _context: Context): Promise<void> { throw new Error('Not implemented'); }
public async exists(_itemId: string, _context: Context): Promise<boolean> { throw new Error('Not implemented'); }
}

View File

@@ -0,0 +1,70 @@
import { clientType } from '../../../db';
import { afterAllTests, beforeAllDb, beforeEachDb, db, expectNotThrow, expectThrow, models } from '../../../utils/testing/testUtils';
import { StorageDriverMode } from '../../../utils/types';
import StorageDriverDatabase from './StorageDriverDatabase';
import StorageDriverMemory from './StorageDriverMemory';
import { shouldDeleteContent, shouldNotCreateItemIfContentNotSaved, shouldNotUpdateItemIfContentNotSaved, shouldSupportFallbackDriver, shouldSupportFallbackDriverInReadWriteMode, shouldUpdateContentStorageIdAfterSwitchingDriver, shouldWriteToContentAndReadItBack } from './testUtils';
const newDriver = () => {
return new StorageDriverDatabase(1, {
dbClientType: clientType(db()),
});
};
describe('StorageDriverDatabase', function() {
beforeAll(async () => {
await beforeAllDb('StorageDriverDatabase');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should write to content and read it back', async function() {
const driver = newDriver();
await shouldWriteToContentAndReadItBack(driver);
});
test('should delete the content', async function() {
const driver = newDriver();
await shouldDeleteContent(driver);
});
test('should not create the item if the content cannot be saved', async function() {
const driver = newDriver();
await shouldNotCreateItemIfContentNotSaved(driver);
});
test('should not update the item if the content cannot be saved', async function() {
const driver = newDriver();
await shouldNotUpdateItemIfContentNotSaved(driver);
});
test('should fail if the item row does not exist', async function() {
const driver = newDriver();
await expectThrow(async () => driver.read('oops', { models: models() }));
});
test('should do nothing if deleting non-existing row', async function() {
const driver = newDriver();
await expectNotThrow(async () => driver.delete('oops', { models: models() }));
});
test('should support fallback content drivers', async function() {
await shouldSupportFallbackDriver(newDriver(), new StorageDriverMemory(2));
});
test('should support fallback content drivers in rw mode', async function() {
await shouldSupportFallbackDriverInReadWriteMode(newDriver(), new StorageDriverMemory(2, { mode: StorageDriverMode.ReadWrite }));
});
test('should update content storage ID after switching driver', async function() {
await shouldUpdateContentStorageIdAfterSwitchingDriver(newDriver(), new StorageDriverMemory(2));
});
});

View File

@@ -0,0 +1,58 @@
// This driver allows storing the content directly with the item row in the
// database (as a binary blob). For now the driver expects that the content is
// stored in the same table as the items, as it originally was.
import { DatabaseConfigClient, StorageDriverConfig, StorageDriverType } from '../../../utils/types';
import StorageDriverBase, { Context } from './StorageDriverBase';
interface StorageDriverDatabaseConfig extends StorageDriverConfig {
dbClientType: DatabaseConfigClient;
}
export default class StorageDriverDatabase extends StorageDriverBase {
private handleReturnedRows_: boolean = null;
public constructor(id: number, config: StorageDriverDatabaseConfig) {
super(id, { type: StorageDriverType.Database, ...config });
this.handleReturnedRows_ = config.dbClientType === DatabaseConfigClient.PostgreSQL;
}
public async write(itemId: string, content: Buffer, context: Context): Promise<void> {
const returningOption = this.handleReturnedRows_ ? ['id'] : undefined;
const updatedRows = await context.models.item().db('items').update({ content }, returningOption).where('id', '=', itemId);
if (!this.handleReturnedRows_) return;
// Not possible because the ID is unique
if (updatedRows.length > 1) throw new Error('Update more than one row');
// Not possible either because the row is created before this handler is called, but still could happen
if (!updatedRows.length) throw new Error(`No such item: ${itemId}`);
// That would be weird
if (updatedRows[0].id !== itemId) throw new Error(`Did not update the right row. Expected: ${itemId}. Got: ${updatedRows[0].id}`);
}
public async read(itemId: string, context: Context): Promise<Buffer> {
const row = await context.models.item().db('items').select('content').where('id', '=', itemId).first();
// Calling code should only call this handler if the row exists, so if
// we find it doesn't, it's an error.
if (!row) throw new Error(`No such row: ${itemId}`);
return row.content;
}
public async delete(_itemId: string | string[], _context: Context): Promise<void> {
// noop because the calling code deletes the whole row, including the
// content.
}
public async exists(itemId: string, context: Context): Promise<boolean> {
const row = await context.models.item().db('items').select('content').where('id', '=', itemId).first();
return !!row && !!row.content;
}
}

View File

@@ -0,0 +1,84 @@
import { pathExists, remove } from 'fs-extra';
import { afterAllTests, beforeAllDb, beforeEachDb, expectNotThrow, expectThrow, tempDirPath } from '../../../utils/testing/testUtils';
import StorageDriverFs from './StorageDriverFs';
import { shouldDeleteContent, shouldNotCreateItemIfContentNotSaved, shouldNotUpdateItemIfContentNotSaved, shouldWriteToContentAndReadItBack } from './testUtils';
let basePath_: string = '';
const newDriver = () => {
return new StorageDriverFs(1, { path: basePath_ });
};
describe('StorageDriverFs', function() {
beforeAll(async () => {
await beforeAllDb('StorageDriverFs');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
basePath_ = tempDirPath();
await beforeEachDb();
});
afterEach(async () => {
await remove(basePath_);
basePath_ = '';
});
test('should write to content and read it back', async function() {
const driver = newDriver();
await shouldWriteToContentAndReadItBack(driver);
});
test('should delete the content', async function() {
const driver = newDriver();
await shouldDeleteContent(driver);
});
test('should not create the item if the content cannot be saved', async function() {
const driver = newDriver();
await shouldNotCreateItemIfContentNotSaved(driver);
});
test('should not update the item if the content cannot be saved', async function() {
const driver = newDriver();
await shouldNotUpdateItemIfContentNotSaved(driver);
});
test('should write to a file and read it back', async function() {
const driver = newDriver();
await driver.write('testing', Buffer.from('testing'));
const content = await driver.read('testing');
expect(content.toString()).toBe('testing');
});
test('should automatically create the base path', async function() {
expect(await pathExists(basePath_)).toBe(false);
const driver = newDriver();
await driver.write('testing', Buffer.from('testing'));
expect(await pathExists(basePath_)).toBe(true);
});
test('should delete a file', async function() {
const driver = newDriver();
await driver.write('testing', Buffer.from('testing'));
expect((await driver.read('testing')).toString()).toBe('testing');
await driver.delete('testing');
await expectThrow(async () => driver.read('testing'), 'ENOENT');
});
test('should throw if the file does not exist when reading it', async function() {
const driver = newDriver();
await expectThrow(async () => driver.read('testread'), 'ENOENT');
});
test('should not throw if deleting a file that does not exist', async function() {
const driver = newDriver();
await expectNotThrow(async () => driver.delete('notthere'));
});
});

View File

@@ -0,0 +1,48 @@
import { mkdirp, pathExists, readFile, remove, writeFile } from 'fs-extra';
import { StorageDriverConfig, StorageDriverType } from '../../../utils/types';
import StorageDriverBase from './StorageDriverBase';
export default class StorageDriverFs extends StorageDriverBase {
private pathCreated_: Record<string, boolean> = {};
public constructor(id: number, config: StorageDriverConfig) {
super(id, { type: StorageDriverType.Filesystem, ...config });
}
private async createParentDirectories(path: string) {
const p = path.split('/');
p.pop();
const basename = p.join('/');
if (this.pathCreated_[basename]) return;
await mkdirp(basename);
this.pathCreated_[basename] = true;
}
private itemPath(itemId: string): string {
return `${this.config.path}/${itemId.substr(0, 2).toLowerCase()}/${itemId.substr(2, 2).toLowerCase()}/${itemId}`;
}
public async write(itemId: string, content: Buffer): Promise<void> {
const itemPath = this.itemPath(itemId);
await this.createParentDirectories(itemPath);
await writeFile(itemPath, content);
}
public async read(itemId: string): Promise<Buffer> {
return readFile(this.itemPath(itemId));
}
public async delete(itemId: string | string[]): Promise<void> {
const itemIds = Array.isArray(itemId) ? itemId : [itemId];
for (const id of itemIds) {
await remove(this.itemPath(id));
}
}
public async exists(itemId: string): Promise<boolean> {
return pathExists(this.itemPath(itemId));
}
}

View File

@@ -0,0 +1,40 @@
import { afterAllTests, beforeAllDb, beforeEachDb } from '../../../utils/testing/testUtils';
import StorageDriverMemory from './StorageDriverMemory';
import { shouldDeleteContent, shouldNotCreateItemIfContentNotSaved, shouldNotUpdateItemIfContentNotSaved, shouldWriteToContentAndReadItBack } from './testUtils';
describe('StorageDriverMemory', function() {
beforeAll(async () => {
await beforeAllDb('StorageDriverMemory');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should write to content and read it back', async function() {
const driver = new StorageDriverMemory(1);
await shouldWriteToContentAndReadItBack(driver);
});
test('should delete the content', async function() {
const driver = new StorageDriverMemory(1);
await shouldDeleteContent(driver);
});
test('should not create the item if the content cannot be saved', async function() {
const driver = new StorageDriverMemory(1);
await shouldNotCreateItemIfContentNotSaved(driver);
});
test('should not update the item if the content cannot be saved', async function() {
const driver = new StorageDriverMemory(1);
await shouldNotUpdateItemIfContentNotSaved(driver);
});
});

View File

@@ -0,0 +1,32 @@
import { StorageDriverConfig, StorageDriverType } from '../../../utils/types';
import StorageDriverBase from './StorageDriverBase';
export default class StorageDriverMemory extends StorageDriverBase {
private data_: Record<string, Buffer> = {};
public constructor(id: number, config: StorageDriverConfig = null) {
super(id, { type: StorageDriverType.Memory, ...config });
}
public async write(itemId: string, content: Buffer): Promise<void> {
this.data_[itemId] = content;
}
public async read(itemId: string): Promise<Buffer> {
if (!(itemId in this.data_)) throw new Error(`No such item: ${itemId}`);
return this.data_[itemId];
}
public async delete(itemId: string | string[]): Promise<void> {
const itemIds = Array.isArray(itemId) ? itemId : [itemId];
for (const id of itemIds) {
delete this.data_[id];
}
}
public async exists(itemId: string): Promise<boolean> {
return itemId in this.data_;
}
}

View File

@@ -0,0 +1,85 @@
// Note that these tests require an S3 bucket to be set, with the credentials
// defined in the below config file. If the credentials are missing, all the
// tests are skipped.
import { afterAllTests, beforeAllDb, beforeEachDb, expectNotThrow, expectThrow, readCredentialFile } from '../../../utils/testing/testUtils';
import { StorageDriverType } from '../../../utils/types';
import StorageDriverS3 from './StorageDriverS3';
import { shouldDeleteContent, shouldNotCreateItemIfContentNotSaved, shouldNotUpdateItemIfContentNotSaved, shouldWriteToContentAndReadItBack } from './testUtils';
const s3Config = async () => {
const s = await readCredentialFile('server-s3-test-units.json', '');
if (!s) return null;
return JSON.parse(s);
};
const newDriver = async () => {
return new StorageDriverS3(1, {
type: StorageDriverType.S3,
...await s3Config(),
});
};
const configIsSet = async () => {
const c = await s3Config();
return !!c;
};
describe('StorageDriverS3', function() {
beforeAll(async () => {
if (!(await configIsSet())) {
return;
} else {
console.warn('Running S3 unit tests on live environment!');
await beforeAllDb('StorageDriverS3');
}
});
afterAll(async () => {
if (!(await configIsSet())) return;
await afterAllTests();
});
beforeEach(async () => {
if (!(await configIsSet())) return;
await beforeEachDb();
});
test('should write to content and read it back', async function() {
if (!(await configIsSet())) return;
const driver = await newDriver();
await shouldWriteToContentAndReadItBack(driver);
});
test('should delete the content', async function() {
if (!(await configIsSet())) return;
const driver = await newDriver();
await shouldDeleteContent(driver);
});
test('should not create the item if the content cannot be saved', async function() {
if (!(await configIsSet())) return;
const driver = await newDriver();
await shouldNotCreateItemIfContentNotSaved(driver);
});
test('should not update the item if the content cannot be saved', async function() {
if (!(await configIsSet())) return;
const driver = await newDriver();
await shouldNotUpdateItemIfContentNotSaved(driver);
});
test('should fail if the item row does not exist', async function() {
if (!(await configIsSet())) return;
const driver = await newDriver();
await expectThrow(async () => driver.read('oops'));
});
test('should do nothing if deleting non-existing row', async function() {
if (!(await configIsSet())) return;
const driver = await newDriver();
await expectNotThrow(async () => driver.delete('oops'));
});
});

View File

@@ -0,0 +1,97 @@
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ObjectIdentifier, HeadObjectCommand } from '@aws-sdk/client-s3';
import { StorageDriverConfig, StorageDriverType } from '../../../utils/types';
import StorageDriverBase from './StorageDriverBase';
function stream2buffer(stream: any): Promise<Buffer> {
return new Promise((resolve, reject) => {
const buffer: Uint8Array[] = [];
let hasError = false;
stream.on('data', (chunk: Uint8Array) => {
if (hasError) return;
buffer.push(chunk);
});
stream.on('end', () => {
if (hasError) return;
resolve(Buffer.concat(buffer));
});
stream.on('error', (error: any) => {
if (hasError) return;
hasError = true;
reject(error);
});
});
}
export default class StorageDriverS3 extends StorageDriverBase {
private client_: S3Client;
public constructor(id: number, config: StorageDriverConfig) {
super(id, { type: StorageDriverType.S3, ...config });
this.client_ = new S3Client({
// We need to set a region. See https://github.com/aws/aws-sdk-js-v3/issues/1845#issuecomment-754832210
region: this.config.region,
credentials: {
accessKeyId: this.config.accessKeyId,
secretAccessKey: this.config.secretAccessKeyId,
},
});
}
public async write(itemId: string, content: Buffer): Promise<void> {
await this.client_.send(new PutObjectCommand({
Bucket: this.config.bucket,
Key: itemId,
Body: content,
}));
}
public async read(itemId: string): Promise<Buffer | null> {
try {
const response = await this.client_.send(new GetObjectCommand({
Bucket: this.config.bucket,
Key: itemId,
}));
return stream2buffer(response.Body);
} catch (error) {
error.message = `Could not get item "${itemId}": ${error.message}`;
throw error;
}
}
public async delete(itemId: string | string[]): Promise<void> {
const itemIds = Array.isArray(itemId) ? itemId : [itemId];
const objects: ObjectIdentifier[] = itemIds.map(id => {
return { Key: id };
});
await this.client_.send(new DeleteObjectsCommand({
Bucket: this.config.bucket,
Delete: {
Objects: objects,
},
}));
}
public async exists(itemId: string): Promise<boolean> {
try {
await this.client_.send(new HeadObjectCommand({
Bucket: this.config.bucket,
Key: itemId,
}));
return true;
} catch (error) {
if (error?.$metadata?.httpStatusCode === 404) return false;
error.message = `Could not check if object exists: "${itemId}": ${error.message}`;
throw error;
}
}
}

View File

@@ -0,0 +1,42 @@
import { StorageDriverConfig, StorageDriverType } from '../../../utils/types';
import parseStorageDriverConnectionString from './parseStorageDriverConnectionString';
describe('parseStorageDriverConnectionString', function() {
test('should parse a connection string', async function() {
const testCases: Record<string, StorageDriverConfig> = {
'Type=Database': {
type: StorageDriverType.Database,
},
' Type = Database ': {
type: StorageDriverType.Database,
},
'Type=Filesystem; Path=/path/to/dir': {
type: StorageDriverType.Filesystem,
path: '/path/to/dir',
},
' Type = Filesystem ; Path = /path/to/dir ': {
type: StorageDriverType.Filesystem,
path: '/path/to/dir',
},
'Type=Memory;': {
type: StorageDriverType.Memory,
},
'': null,
};
for (const [connectionString, config] of Object.entries(testCases)) {
const actual = parseStorageDriverConnectionString(connectionString);
expect(actual).toEqual(config);
}
});
test('should detect errors', async function() {
expect(() => parseStorageDriverConnectionString('Path=/path/to/dir')).toThrow(); // Type is missing
expect(() => parseStorageDriverConnectionString('Type=')).toThrow();
expect(() => parseStorageDriverConnectionString('Type;')).toThrow();
expect(() => parseStorageDriverConnectionString('Type=DoesntExist')).toThrow();
expect(() => parseStorageDriverConnectionString('Type=Filesystem')).toThrow();
});
});

View File

@@ -0,0 +1,63 @@
// Type={Database,Filesystem,Memory,S3}; Path={/path/to/dir,https://s3bucket}
import { StorageDriverConfig, StorageDriverMode, StorageDriverType } from '../../../utils/types';
const parseType = (type: string): StorageDriverType => {
if (type === 'Database') return StorageDriverType.Database;
if (type === 'Filesystem') return StorageDriverType.Filesystem;
if (type === 'Memory') return StorageDriverType.Memory;
throw new Error(`Invalid type: "${type}"`);
};
const parseMode = (mode: string): StorageDriverMode => {
if (mode === 'rw') return StorageDriverMode.ReadWrite;
if (mode === 'r') return StorageDriverMode.ReadOnly;
throw new Error(`Invalid type: "${mode}"`);
};
const validate = (config: StorageDriverConfig) => {
if (!config.type) throw new Error('Type must be specified');
if (config.type === StorageDriverType.Filesystem && !config.path) throw new Error('Path must be set for filesystem driver');
return config;
};
export default function(connectionString: string): StorageDriverConfig | null {
if (!connectionString) return null;
const output: StorageDriverConfig = {
type: null,
};
const items = connectionString.split(';').map(i => i.trim());
try {
for (const item of items) {
if (!item) continue;
const [key, value] = item.split('=').map(s => s.trim());
if (key === 'Type') {
output.type = parseType(value);
} else if (key === 'Path') {
output.path = value;
} else if (key === 'Mode') {
output.mode = parseMode(value);
} else if (key === 'Region') {
output.region = value;
} else if (key === 'AccessKeyId') {
output.accessKeyId = value;
} else if (key === 'SecretAccessKeyId') {
output.secretAccessKeyId = value;
} else if (key === 'Bucket') {
output.bucket = value;
} else {
throw new Error(`Invalid key: "${key}"`);
}
}
} catch (error) {
error.message = `In connection string "${connectionString}": ${error.message}`;
throw error;
}
return validate(output);
}

View File

@@ -0,0 +1,30 @@
import { StorageDriverConfig, StorageDriverMode, StorageDriverType } from '../../../utils/types';
const serializeType = (type: StorageDriverType): string => {
if (type === StorageDriverType.Database) return 'Database';
if (type === StorageDriverType.Filesystem) return 'Filesystem';
if (type === StorageDriverType.Memory) return 'Memory';
throw new Error(`Invalid type: "${type}"`);
};
const serializeMode = (mode: StorageDriverMode): string => {
if (mode === StorageDriverMode.ReadWrite) return 'rw';
if (mode === StorageDriverMode.ReadOnly) return 'r';
throw new Error(`Invalid type: "${mode}"`);
};
export default function(config: StorageDriverConfig, locationOnly: boolean = true): string {
if (!config) return '';
const items: string[] = [];
items.push(`Type=${serializeType(config.type)}`);
if (config.path) items.push(`Path=${config.path}`);
if (!locationOnly && config.mode) items.push(`Mode=${serializeMode(config.mode)}`);
items.sort();
return items.join('; ');
}

View File

@@ -0,0 +1,54 @@
import globalConfig from '../../../config';
import { clientType, DbConnection } from '../../../db';
import { StorageDriverConfig, StorageDriverType } from '../../../utils/types';
import newModelFactory from '../../factory';
import serializeStorageConfig from './serializeStorageConfig';
import StorageDriverBase from './StorageDriverBase';
import StorageDriverDatabase from './StorageDriverDatabase';
import StorageDriverFs from './StorageDriverFs';
import StorageDriverMemory from './StorageDriverMemory';
export interface Options {
assignDriverId?: boolean;
}
export default async function(config: StorageDriverConfig, db: DbConnection, options: Options = null): Promise<StorageDriverBase | null> {
if (!config) return null;
options = {
assignDriverId: true,
...options,
};
let storageId: number = 0;
if (options.assignDriverId) {
const models = newModelFactory(db, globalConfig(), { storageDriver: null });
const connectionString = serializeStorageConfig(config);
const existingStorage = await models.storage().byConnectionString(connectionString);
if (existingStorage) {
storageId = existingStorage.id;
} else {
const storage = await models.storage().save({
connection_string: connectionString,
});
storageId = storage.id;
}
}
if (config.type === StorageDriverType.Database) {
return new StorageDriverDatabase(storageId, { ...config, dbClientType: clientType(db) });
}
if (config.type === StorageDriverType.Filesystem) {
return new StorageDriverFs(storageId, config);
}
if (config.type === StorageDriverType.Memory) {
return new StorageDriverMemory(storageId, config);
}
throw new Error(`Invalid config: ${JSON.stringify(config)}`);
}

View File

@@ -0,0 +1,245 @@
import { Item } from '../../../services/database/types';
import { createUserAndSession, makeNoteSerializedBody, models } from '../../../utils/testing/testUtils';
import { StorageDriverMode } from '../../../utils/types';
import StorageDriverBase, { Context } from './StorageDriverBase';
const testModels = (driver: StorageDriverBase) => {
return models({ storageDriver: driver });
};
export async function shouldWriteToContentAndReadItBack(driver: StorageDriverBase) {
const { user } = await createUserAndSession(1);
const noteBody = makeNoteSerializedBody({
id: '00000000000000000000000000000001',
title: 'testing driver',
});
const output = await testModels(driver).item().saveFromRawContent(user, [{
name: '00000000000000000000000000000001.md',
body: Buffer.from(noteBody),
}]);
const result = output['00000000000000000000000000000001.md'];
expect(result.error).toBeFalsy();
const item = await testModels(driver).item().loadWithContent(result.item.id);
expect(item.content.byteLength).toBe(item.content_size);
expect(item.content_storage_id).toBe(driver.storageId);
const rawContent = await driver.read(item.id, { models: models() });
expect(rawContent.byteLength).toBe(item.content_size);
const jopItem = testModels(driver).item().itemToJoplinItem(item);
expect(jopItem.id).toBe('00000000000000000000000000000001');
expect(jopItem.title).toBe('testing driver');
}
export async function shouldDeleteContent(driver: StorageDriverBase) {
const { user } = await createUserAndSession(1);
const noteBody = makeNoteSerializedBody({
id: '00000000000000000000000000000001',
title: 'testing driver',
});
const output = await testModels(driver).item().saveFromRawContent(user, [{
name: '00000000000000000000000000000001.md',
body: Buffer.from(noteBody),
}]);
const item: Item = output['00000000000000000000000000000001.md'].item;
expect((await testModels(driver).item().all()).length).toBe(1);
await testModels(driver).item().delete(item.id);
expect((await testModels(driver).item().all()).length).toBe(0);
}
export async function shouldNotCreateItemIfContentNotSaved(driver: StorageDriverBase) {
const previousWrite = driver.write;
driver.write = () => { throw new Error('not working!'); };
try {
const { user } = await createUserAndSession(1);
const noteBody = makeNoteSerializedBody({
id: '00000000000000000000000000000001',
title: 'testing driver',
});
const output = await testModels(driver).item().saveFromRawContent(user, [{
name: '00000000000000000000000000000001.md',
body: Buffer.from(noteBody),
}]);
expect(output['00000000000000000000000000000001.md'].error.message).toBe('not working!');
expect((await testModels(driver).item().all()).length).toBe(0);
} finally {
driver.write = previousWrite;
}
}
export async function shouldNotUpdateItemIfContentNotSaved(driver: StorageDriverBase) {
const { user } = await createUserAndSession(1);
const noteBody = makeNoteSerializedBody({
id: '00000000000000000000000000000001',
title: 'testing driver',
});
await testModels(driver).item().saveFromRawContent(user, [{
name: '00000000000000000000000000000001.md',
body: Buffer.from(noteBody),
}]);
const noteBodyMod1 = makeNoteSerializedBody({
id: '00000000000000000000000000000001',
title: 'updated 1',
});
await testModels(driver).item().saveFromRawContent(user, [{
name: '00000000000000000000000000000001.md',
body: Buffer.from(noteBodyMod1),
}]);
const itemMod1 = testModels(driver).item().itemToJoplinItem(await testModels(driver).item().loadByJopId(user.id, '00000000000000000000000000000001', { withContent: true }));
expect(itemMod1.title).toBe('updated 1');
const noteBodyMod2 = makeNoteSerializedBody({
id: '00000000000000000000000000000001',
title: 'updated 2',
});
const previousWrite = driver.write;
driver.write = () => { throw new Error('not working!'); };
try {
const output = await testModels(driver).item().saveFromRawContent(user, [{
name: '00000000000000000000000000000001.md',
body: Buffer.from(noteBodyMod2),
}]);
expect(output['00000000000000000000000000000001.md'].error.message).toBe('not working!');
const itemMod2 = testModels(driver).item().itemToJoplinItem(await testModels(driver).item().loadByJopId(user.id, '00000000000000000000000000000001', { withContent: true }));
expect(itemMod2.title).toBe('updated 1'); // Check it has not been updated
} finally {
driver.write = previousWrite;
}
}
export async function shouldSupportFallbackDriver(driver: StorageDriverBase, fallbackDriver: StorageDriverBase) {
const { user } = await createUserAndSession(1);
const output = await testModels(driver).item().saveFromRawContent(user, [{
name: '00000000000000000000000000000001.md',
body: Buffer.from(makeNoteSerializedBody({
id: '00000000000000000000000000000001',
title: 'testing',
})),
}]);
const itemId = output['00000000000000000000000000000001.md'].item.id;
let previousByteLength = 0;
{
const content = await driver.read(itemId, { models: models() });
expect(content.byteLength).toBeGreaterThan(10);
previousByteLength = content.byteLength;
}
const testModelWithFallback = models({
storageDriver: driver,
storageDriverFallback: fallbackDriver,
});
// If the item content is not on the main content driver, it should get
// it from the fallback one.
const itemFromDb = await testModelWithFallback.item().loadWithContent(itemId);
expect(itemFromDb.content.byteLength).toBe(previousByteLength);
// When writing content, it should use the main content driver, and set
// the content for the fallback one to "".
await testModelWithFallback.item().saveFromRawContent(user, [{
name: '00000000000000000000000000000001.md',
body: Buffer.from(makeNoteSerializedBody({
id: '00000000000000000000000000000001',
title: 'testing1234',
})),
}]);
{
// Check that it has cleared the fallback driver content
const context: Context = { models: models() };
const fallbackContent = await fallbackDriver.read(itemId, context);
expect(fallbackContent.byteLength).toBe(0);
// Check that it has written to the main driver content
const mainContent = await driver.read(itemId, context);
expect(mainContent.byteLength).toBe(previousByteLength + 4);
}
}
export async function shouldSupportFallbackDriverInReadWriteMode(driver: StorageDriverBase, fallbackDriver: StorageDriverBase) {
if (fallbackDriver.mode !== StorageDriverMode.ReadWrite) throw new Error('Content driver must be configured in RW mode for this test');
const { user } = await createUserAndSession(1);
const testModelWithFallback = models({
storageDriver: driver,
storageDriverFallback: fallbackDriver,
});
const output = await testModelWithFallback.item().saveFromRawContent(user, [{
name: '00000000000000000000000000000001.md',
body: Buffer.from(makeNoteSerializedBody({
id: '00000000000000000000000000000001',
title: 'testing',
})),
}]);
const itemId = output['00000000000000000000000000000001.md'].item.id;
{
// Check that it has written the content to both drivers
const context: Context = { models: models() };
const fallbackContent = await fallbackDriver.read(itemId, context);
expect(fallbackContent.byteLength).toBeGreaterThan(10);
const mainContent = await driver.read(itemId, context);
expect(mainContent.toString()).toBe(fallbackContent.toString());
}
}
export async function shouldUpdateContentStorageIdAfterSwitchingDriver(oldDriver: StorageDriverBase, newDriver: StorageDriverBase) {
if (oldDriver.storageId === newDriver.storageId) throw new Error('Drivers must be different for this test');
const { user } = await createUserAndSession(1);
const oldDriverModel = models({
storageDriver: oldDriver,
});
const newDriverModel = models({
storageDriver: newDriver,
});
const output = await oldDriverModel.item().saveFromRawContent(user, [{
name: '00000000000000000000000000000001.md',
body: Buffer.from(makeNoteSerializedBody({
id: '00000000000000000000000000000001',
title: 'testing',
})),
}]);
const itemId = output['00000000000000000000000000000001.md'].item.id;
expect((await oldDriverModel.item().load(itemId)).content_storage_id).toBe(oldDriver.storageId);
await newDriverModel.item().saveFromRawContent(user, [{
name: '00000000000000000000000000000001.md',
body: Buffer.from(makeNoteSerializedBody({
id: '00000000000000000000000000000001',
title: 'testing',
})),
}]);
expect(await newDriverModel.item().count()).toBe(1);
expect((await oldDriverModel.item().load(itemId)).content_storage_id).toBe(newDriver.storageId);
}

View File

@@ -22,9 +22,9 @@ describe('index_notification', function() {
const model = models().notification();
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Normal, 'testing notification');
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Normal, 'testing notification');
const notification = await model.loadByKey(user.id, NotificationKey.ConfirmEmail);
const notification = await model.loadByKey(user.id, NotificationKey.EmailConfirmed);
expect(notification.read).toBe(0);
@@ -41,7 +41,7 @@ describe('index_notification', function() {
await routeHandler(context);
expect((await model.loadByKey(user.id, NotificationKey.ConfirmEmail)).read).toBe(1);
expect((await model.loadByKey(user.id, NotificationKey.EmailConfirmed)).read).toBe(1);
});
});

View File

@@ -22,7 +22,7 @@ async function renderItem(context: AppContext, item: Item, share: Share): Promis
}
function createContentDispositionHeader(filename: string) {
const encoded = encodeURIComponent(friendlySafeFilename(filename));
const encoded = encodeURIComponent(friendlySafeFilename(filename, null, true));
return `attachment; filename*=UTF-8''${encoded}; filename="${encoded}"`;
}

View File

@@ -1,5 +1,4 @@
import config from '../../config';
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType } from '../../models/UserModel';
import { getCanShareFolder, getMaxItemSize } from '../../models/utils/user';
import { MB } from '../../utils/bytes';
@@ -53,11 +52,6 @@ describe('index_signup', function() {
// Check that the user is logged in
const session = await models().session().load(cookieGet(context, 'sessionId'));
expect(session.user_id).toBe(user.id);
// Check that the notification has been created
const notifications = await models().notification().allUnreadByUserId(user.id);
expect(notifications.length).toBe(1);
expect(notifications[0].key).toBe(NotificationKey.ConfirmEmail);
});
});

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