You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-23 23:33:01 +02:00
Compare commits
49 Commits
share_e2ee
...
server_con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b5ef1886 | ||
|
|
7b3ad32103 | ||
|
|
3745cd7cb0 | ||
|
|
920f2d9655 | ||
|
|
f800ca0269 | ||
|
|
33be306d01 | ||
|
|
3782255c27 | ||
|
|
68f77f6bbc | ||
|
|
0ab235273b | ||
|
|
75256613cc | ||
|
|
b328094033 | ||
|
|
4f0f1af5d1 | ||
|
|
021ce14348 | ||
|
|
b402bc7ff7 | ||
|
|
0ed0690bf8 | ||
|
|
467b1156cc | ||
|
|
c4017e52dc | ||
|
|
ec2c1741a2 | ||
|
|
6a9d9f6542 | ||
|
|
3e5ad0a374 | ||
|
|
05e390d48b | ||
|
|
31ce0f46e0 | ||
|
|
d19551b984 | ||
|
|
dacd697f80 | ||
|
|
70d5c7a648 | ||
|
|
69b413ce2b | ||
|
|
42caab6bde | ||
|
|
01b63ad263 | ||
|
|
ae4013d2f7 | ||
|
|
e3d6334372 | ||
|
|
cc4c50c219 | ||
|
|
5d646f7ced | ||
|
|
fa3612405c | ||
|
|
20df46c066 | ||
|
|
9b0a659416 | ||
|
|
298e85f115 | ||
|
|
a00e0e7043 | ||
|
|
9e1cb9db2c | ||
|
|
373c041aa6 | ||
|
|
af19865865 | ||
|
|
560523bdc2 | ||
|
|
a13242e803 | ||
|
|
72834fcfc4 | ||
|
|
731142218b | ||
|
|
17b580b71b | ||
|
|
f7be45c236 | ||
|
|
b298861dc3 | ||
|
|
2343de3763 | ||
|
|
abb37258d0 |
@@ -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
|
||||
|
||||
32
.github/scripts/run_ci.sh
vendored
32
.github/scripts/run_ci.sh
vendored
@@ -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"
|
||||
|
||||
8
.github/workflows/github-actions-main.yml
vendored
8
.github/workflows/github-actions-main.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -188,6 +188,11 @@ h2 {
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.3em;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.front-page h1 {
|
||||
font-size: 3em;
|
||||
}
|
||||
|
||||
76
README.md
76
README.md
@@ -505,47 +505,47 @@ Current translations:
|
||||
<!-- LOCALE-TABLE-AUTO-GENERATED -->
|
||||
| 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
50
packages/app-mobile/package-lock.json
generated
50
packages/app-mobile/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
}));
|
||||
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'onclick=javascript:alert(/1/);f='\' onclick=\'postMessage("https://www.goo%27onclick=javascript:alert(/1/);f=%27", { resourceId: "" }); return false;\'>');
|
||||
});
|
||||
|
||||
test('should handle resource links - downloaded status', () => {
|
||||
const resourceId = 'f6afba55bdf74568ac94f8d1e3578d2c';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
3677
packages/server/package-lock.json
generated
3677
packages/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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.
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
}
|
||||
|
||||
32
packages/server/src/migrations/20211105183559_storage.ts
Normal file
32
packages/server/src/migrations/20211105183559_storage.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
@@ -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_;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
18
packages/server/src/models/StorageModel.ts
Normal file
18
packages/server/src/models/StorageModel.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'); }
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
});
|
||||
48
packages/server/src/models/items/storage/StorageDriverFs.ts
Normal file
48
packages/server/src/models/items/storage/StorageDriverFs.ts
Normal 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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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_;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
});
|
||||
97
packages/server/src/models/items/storage/StorageDriverS3.ts
Normal file
97
packages/server/src/models/items/storage/StorageDriverS3.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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('; ');
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
245
packages/server/src/models/items/storage/testUtils.ts
Normal file
245
packages/server/src/models/items/storage/testUtils.ts
Normal 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);
|
||||
}
|
||||
0
packages/server/src/models/items/storage/utils.ts
Normal file
0
packages/server/src/models/items/storage/utils.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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}"`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user