Compare commits
122 Commits
ci_fix_rsa
...
sharing_bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f53bb2d167 | ||
|
|
4231f8cced | ||
|
|
3f9c60dd10 | ||
|
|
35e189ef6e | ||
|
|
a15bad37b1 | ||
|
|
8b4ad0aaf7 | ||
|
|
c3575672b2 | ||
|
|
e840d0c3fd | ||
|
|
5227ba1adb | ||
|
|
ea49907327 | ||
|
|
0dd90a7542 | ||
|
|
a962f48b38 | ||
|
|
f68d2bbc7c | ||
|
|
65c9665a2a | ||
|
|
2c50ad36c5 | ||
|
|
7212269107 | ||
|
|
1387470f2a | ||
|
|
a6d5eb9b8e | ||
|
|
5d1a055d2a | ||
|
|
36910a2a9b | ||
|
|
b4a57a10aa | ||
|
|
bca8cb1c2d | ||
|
|
0b489a9c98 | ||
|
|
ce32651794 | ||
|
|
f0159cdd89 | ||
|
|
97652fa362 | ||
|
|
113b259b81 | ||
|
|
2af895477f | ||
|
|
c7e31d1ac9 | ||
|
|
c51b13ca73 | ||
|
|
f5febb18b4 | ||
|
|
9d6aa1c739 | ||
|
|
3b27f84996 | ||
|
|
fc38691f3a | ||
|
|
d2274319f9 | ||
|
|
a40448fed9 | ||
|
|
5ec79c74e2 | ||
|
|
bbba19eb40 | ||
|
|
75b89c7e09 | ||
|
|
f9af9a724c | ||
|
|
6e7c9c059d | ||
|
|
69ee435b0b | ||
|
|
204f1bf509 | ||
|
|
7a7a2c4cec | ||
|
|
441486acaa | ||
|
|
4684142df7 | ||
|
|
0a871ea44b | ||
|
|
901fe73c08 | ||
|
|
41553eb963 | ||
|
|
cada200575 | ||
|
|
13711c6a9c | ||
|
|
1a6acee5c8 | ||
|
|
0c2547a780 | ||
|
|
e0204d672b | ||
|
|
9c9b06de2d | ||
|
|
58f3344564 | ||
|
|
f6fef5a8ec | ||
|
|
e0211045db | ||
|
|
f757221d44 | ||
|
|
552ecc9064 | ||
|
|
7d4864193f | ||
|
|
81e2205a53 | ||
|
|
4e89890a23 | ||
|
|
60de33b8be | ||
|
|
84d6f5dfcb | ||
|
|
d0d80c0e4a | ||
|
|
798c1e1c2b | ||
|
|
1eef44d243 | ||
|
|
e5adaa7f74 | ||
|
|
671997af96 | ||
|
|
2bf968f9ad | ||
|
|
3e06dd989f | ||
|
|
3459355285 | ||
|
|
7406a89dc0 | ||
|
|
ace662cc79 | ||
|
|
0c5d5e59f3 | ||
|
|
b00aadb542 | ||
|
|
d6883e6ec1 | ||
|
|
6ac64ca0d9 | ||
|
|
9890d267a1 | ||
|
|
1a1335a7d5 | ||
|
|
67288f0b44 | ||
|
|
a0cd09cd5b | ||
|
|
6e5623ce6a | ||
|
|
032f26b1c5 | ||
|
|
d0030a904c | ||
|
|
a23d5d10b6 | ||
|
|
f9ccd15615 | ||
|
|
1f9f63d176 | ||
|
|
813f077312 | ||
|
|
6a5c85d3d7 | ||
|
|
1644f56447 | ||
|
|
85518edca1 | ||
|
|
ebc070b3c7 | ||
|
|
a33fb575fd | ||
|
|
ecc781ee39 | ||
|
|
098cabad40 | ||
|
|
4d01738029 | ||
|
|
3433293a0e | ||
|
|
02fd244096 | ||
|
|
00cd26fd82 | ||
|
|
38ca224a16 | ||
|
|
0fec577932 | ||
|
|
780d049502 | ||
|
|
a5d74e1ee7 | ||
|
|
d6b369b4f4 | ||
|
|
572e40c635 | ||
|
|
4af5c609fd | ||
|
|
8487fc1a34 | ||
|
|
a76fad3ddf | ||
|
|
a08af91153 | ||
|
|
3bcf221e52 | ||
|
|
0dd211c2fd | ||
|
|
b6fea2a4e2 | ||
|
|
73eb6cca38 | ||
|
|
449f49379d | ||
|
|
c4b951544b | ||
|
|
5746d4cdf6 | ||
|
|
71e4f35e79 | ||
|
|
5169371b68 | ||
|
|
24845bd7d8 | ||
|
|
00b7726cda |
@@ -122,6 +122,8 @@ packages/app-cli/app/command-rmnote.test.js
|
||||
packages/app-cli/app/command-rmnote.js
|
||||
packages/app-cli/app/command-set.js
|
||||
packages/app-cli/app/command-settingschema.js
|
||||
packages/app-cli/app/command-share.test.js
|
||||
packages/app-cli/app/command-share.js
|
||||
packages/app-cli/app/command-sync.js
|
||||
packages/app-cli/app/command-testing.js
|
||||
packages/app-cli/app/command-use.js
|
||||
@@ -130,6 +132,8 @@ packages/app-cli/app/gui/FolderListWidget.js
|
||||
packages/app-cli/app/gui/StatusBarWidget.js
|
||||
packages/app-cli/app/services/plugins/PluginRunner.js
|
||||
packages/app-cli/app/setupCommand.js
|
||||
packages/app-cli/app/utils/initializeCommandService.js
|
||||
packages/app-cli/app/utils/shimInitCli.js
|
||||
packages/app-cli/app/utils/testUtils.js
|
||||
packages/app-cli/tests/HtmlToMd.js
|
||||
packages/app-cli/tests/MarkupToHtml.js
|
||||
@@ -449,7 +453,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
@@ -601,6 +604,7 @@ packages/app-desktop/utils/isSafeToOpen.test.js
|
||||
packages/app-desktop/utils/isSafeToOpen.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.js
|
||||
packages/app-desktop/utils/sourceMapSetup.js
|
||||
packages/app-desktop/utils/window/types.js
|
||||
packages/app-mobile/PluginAssetsLoader.js
|
||||
packages/app-mobile/commands/dismissPluginPanels.js
|
||||
@@ -617,12 +621,16 @@ packages/app-mobile/components/BottomDrawer.js
|
||||
packages/app-mobile/components/CameraView/ActionButtons.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.jest.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.web.js
|
||||
packages/app-mobile/components/CameraView/Camera/types.js
|
||||
packages/app-mobile/components/CameraView/CameraView.test.js
|
||||
packages/app-mobile/components/CameraView/CameraView.js
|
||||
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
|
||||
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
|
||||
packages/app-mobile/components/CameraView/ScannedBarcodes.js
|
||||
packages/app-mobile/components/CameraView/types.js
|
||||
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
|
||||
packages/app-mobile/components/CameraView/utils/testing.js
|
||||
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
|
||||
packages/app-mobile/components/Checkbox.js
|
||||
packages/app-mobile/components/DialogManager/PromptButton.js
|
||||
@@ -888,6 +896,7 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
|
||||
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
|
||||
packages/app-mobile/utils/getPackageInfo.js
|
||||
packages/app-mobile/utils/getVersionInfoText.js
|
||||
packages/app-mobile/utils/hooks/useBackHandler.js
|
||||
packages/app-mobile/utils/hooks/useKeyboardState.js
|
||||
packages/app-mobile/utils/hooks/useOnLongPressProps.js
|
||||
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
|
||||
@@ -928,7 +937,6 @@ packages/default-plugins/commands/editPatch.js
|
||||
packages/default-plugins/utils/getCurrentCommitHash.js
|
||||
packages/default-plugins/utils/getPathToPatchFileFor.js
|
||||
packages/default-plugins/utils/readRepositoryJson.js
|
||||
packages/default-plugins/utils/waitForCliInput.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5BuiltInOptions.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js
|
||||
@@ -1076,6 +1084,7 @@ packages/lib/commands/deleteNote.js
|
||||
packages/lib/commands/historyBackward.js
|
||||
packages/lib/commands/historyForward.js
|
||||
packages/lib/commands/index.js
|
||||
packages/lib/commands/leaveSharedFolder.js
|
||||
packages/lib/commands/openMasterPasswordDialog.js
|
||||
packages/lib/commands/permanentlyDeleteNote.js
|
||||
packages/lib/commands/renderMarkup.test.js
|
||||
@@ -1639,6 +1648,18 @@ packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/fuzzer/ActionTracker.js
|
||||
packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/retryWithCount.js
|
||||
packages/tools/generate-database-types.js
|
||||
packages/tools/generate-images.js
|
||||
packages/tools/git-changelog.test.js
|
||||
|
||||
@@ -23,6 +23,7 @@ module.exports = {
|
||||
'FileSystemCreateWritableOptions': 'readonly',
|
||||
'FileSystemHandle': 'readonly',
|
||||
'IDBTransactionMode': 'readonly',
|
||||
'BigInt': 'readonly',
|
||||
'globalThis': 'readonly',
|
||||
|
||||
// ServiceWorker
|
||||
|
||||
4
.github/workflows/build-macos-m1.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
with:
|
||||
# We need to pin the version to 18.15, because 18.16+ fails with this error:
|
||||
# https://github.com/facebook/react-native/issues/36440
|
||||
node-version: '18.15.0'
|
||||
node-version: '18.20.8'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
# See github-action-main.yml for explanation
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Set Publish Flag
|
||||
run: |
|
||||
|
||||
3
.github/workflows/github-actions-main.yml
vendored
@@ -122,7 +122,6 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn
|
||||
run: |
|
||||
@@ -154,7 +153,7 @@ jobs:
|
||||
docker run -p 22300:22300 joplin/server:$(dpkg --print-architecture)-0.0.0 node dist/app.js --env dev &
|
||||
|
||||
# Wait for server to start
|
||||
sleep 30
|
||||
sleep 120
|
||||
|
||||
# Check if status code is correct
|
||||
# if the actual_status DOES NOT include the expected_status
|
||||
|
||||
@@ -50,13 +50,14 @@ runs:
|
||||
- uses: olegtarasov/get-tag@v2.1.4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: ${{ runner.os != 'Windows' }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.18.0'
|
||||
node-version: '18.20.8'
|
||||
# Disable the cache on ARM runners. For now, we don't run "yarn install" on these
|
||||
# environments and this breaks actions/setup-node.
|
||||
# See https://github.com/laurent22/joplin/commit/47d0d3eb9e89153a609fb5441344da10904c6308#commitcomment-159577783.
|
||||
cache: ${{ (!contains(runner.os, 'arm') && 'yarn') || '' }}
|
||||
# cache: ${{ (!contains(runner.os, 'arm') && 'yarn') || '' }}
|
||||
|
||||
- name: Install Yarn
|
||||
shell: bash
|
||||
@@ -71,4 +72,4 @@ runs:
|
||||
# Ref: https://github.com/nodejs/node-gyp/issues/2869
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '3.13'
|
||||
|
||||
2
.github/workflows/ui-tests.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-13, ubuntu-22.04, windows-2025]
|
||||
os: [ubuntu-22.04, windows-2025]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup build environment
|
||||
|
||||
25
.gitignore
vendored
@@ -97,6 +97,8 @@ packages/app-cli/app/command-rmnote.test.js
|
||||
packages/app-cli/app/command-rmnote.js
|
||||
packages/app-cli/app/command-set.js
|
||||
packages/app-cli/app/command-settingschema.js
|
||||
packages/app-cli/app/command-share.test.js
|
||||
packages/app-cli/app/command-share.js
|
||||
packages/app-cli/app/command-sync.js
|
||||
packages/app-cli/app/command-testing.js
|
||||
packages/app-cli/app/command-use.js
|
||||
@@ -105,6 +107,8 @@ packages/app-cli/app/gui/FolderListWidget.js
|
||||
packages/app-cli/app/gui/StatusBarWidget.js
|
||||
packages/app-cli/app/services/plugins/PluginRunner.js
|
||||
packages/app-cli/app/setupCommand.js
|
||||
packages/app-cli/app/utils/initializeCommandService.js
|
||||
packages/app-cli/app/utils/shimInitCli.js
|
||||
packages/app-cli/app/utils/testUtils.js
|
||||
packages/app-cli/tests/HtmlToMd.js
|
||||
packages/app-cli/tests/MarkupToHtml.js
|
||||
@@ -424,7 +428,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
@@ -576,6 +579,7 @@ packages/app-desktop/utils/isSafeToOpen.test.js
|
||||
packages/app-desktop/utils/isSafeToOpen.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.js
|
||||
packages/app-desktop/utils/sourceMapSetup.js
|
||||
packages/app-desktop/utils/window/types.js
|
||||
packages/app-mobile/PluginAssetsLoader.js
|
||||
packages/app-mobile/commands/dismissPluginPanels.js
|
||||
@@ -592,12 +596,16 @@ packages/app-mobile/components/BottomDrawer.js
|
||||
packages/app-mobile/components/CameraView/ActionButtons.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.jest.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.web.js
|
||||
packages/app-mobile/components/CameraView/Camera/types.js
|
||||
packages/app-mobile/components/CameraView/CameraView.test.js
|
||||
packages/app-mobile/components/CameraView/CameraView.js
|
||||
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
|
||||
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
|
||||
packages/app-mobile/components/CameraView/ScannedBarcodes.js
|
||||
packages/app-mobile/components/CameraView/types.js
|
||||
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
|
||||
packages/app-mobile/components/CameraView/utils/testing.js
|
||||
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
|
||||
packages/app-mobile/components/Checkbox.js
|
||||
packages/app-mobile/components/DialogManager/PromptButton.js
|
||||
@@ -863,6 +871,7 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
|
||||
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
|
||||
packages/app-mobile/utils/getPackageInfo.js
|
||||
packages/app-mobile/utils/getVersionInfoText.js
|
||||
packages/app-mobile/utils/hooks/useBackHandler.js
|
||||
packages/app-mobile/utils/hooks/useKeyboardState.js
|
||||
packages/app-mobile/utils/hooks/useOnLongPressProps.js
|
||||
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
|
||||
@@ -903,7 +912,6 @@ packages/default-plugins/commands/editPatch.js
|
||||
packages/default-plugins/utils/getCurrentCommitHash.js
|
||||
packages/default-plugins/utils/getPathToPatchFileFor.js
|
||||
packages/default-plugins/utils/readRepositoryJson.js
|
||||
packages/default-plugins/utils/waitForCliInput.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5BuiltInOptions.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js
|
||||
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js
|
||||
@@ -1051,6 +1059,7 @@ packages/lib/commands/deleteNote.js
|
||||
packages/lib/commands/historyBackward.js
|
||||
packages/lib/commands/historyForward.js
|
||||
packages/lib/commands/index.js
|
||||
packages/lib/commands/leaveSharedFolder.js
|
||||
packages/lib/commands/openMasterPasswordDialog.js
|
||||
packages/lib/commands/permanentlyDeleteNote.js
|
||||
packages/lib/commands/renderMarkup.test.js
|
||||
@@ -1614,6 +1623,18 @@ packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/fuzzer/ActionTracker.js
|
||||
packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/retryWithCount.js
|
||||
packages/tools/generate-database-types.js
|
||||
packages/tools/generate-images.js
|
||||
packages/tools/git-changelog.test.js
|
||||
|
||||
3
.vscode/settings.json
vendored
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"cSpell.enabled": true
|
||||
"cSpell.enabled": true,
|
||||
"editor.insertSpaces": false
|
||||
}
|
||||
@@ -1300,4 +1300,9 @@ footer .bottom-links-row p {
|
||||
|
||||
:lang(zh-cn) #plans-section .faq {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.cfa-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
BIN
Assets/WebsiteAssets/images/joplin_server_business/main.png
Normal file
|
After Width: | Height: | Size: 430 KiB |
BIN
Assets/WebsiteAssets/images/joplin_server_business/publish.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
Assets/WebsiteAssets/images/joplin_server_business/self_host.jpg
Normal file
|
After Width: | Height: | Size: 434 KiB |
BIN
Assets/WebsiteAssets/images/joplin_server_business/share.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
Assets/WebsiteAssets/images/joplin_server_business/teams.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
Assets/WebsiteAssets/images/sponsors/BestEtf.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
Assets/WebsiteAssets/images/sponsors/Freespinny.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
Assets/WebsiteAssets/images/sponsors/HomeworkGuy.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
@@ -1,24 +1,28 @@
|
||||
<div class="col-12 col-lg-4 account-type-{{priceMonthly.accountType}}">
|
||||
<div class="col-12 col-lg-4 account-type-{{priceMonthly.accountType}} hosting-type-{{hostingType}}">
|
||||
<div class="price-container {{#featured}}price-container-blue{{/featured}}">
|
||||
<div class="price-row">
|
||||
<div class="plan-type">
|
||||
<img src="{{imageBaseUrl}}/{{iconName}}.png"/> {{title}}
|
||||
<div class="price-row">
|
||||
<div class="plan-type">
|
||||
<img src="{{imageBaseUrl}}/{{iconName}}.png"/> {{title}}
|
||||
</div>
|
||||
|
||||
{{#priceMonthly.formattedMonthlyAmount}}
|
||||
<div class="plan-price plan-price-monthly">
|
||||
{{priceMonthly.formattedMonthlyAmount}}<sub class="per-month"> <span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub>
|
||||
</div>
|
||||
|
||||
<div class="plan-price plan-price-yearly">
|
||||
{{priceYearly.formattedMonthlyAmount}}<sub class="per-month"> <span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub>
|
||||
</div>
|
||||
{{/priceMonthly.formattedMonthlyAmount}}
|
||||
</div>
|
||||
|
||||
<div class="plan-price plan-price-monthly">
|
||||
{{priceMonthly.formattedMonthlyAmount}}<sub class="per-month"> <span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub>
|
||||
{{#priceYearly.formattedMonthlyAmount}}
|
||||
<div class="plan-price-yearly-per-year">
|
||||
<div>
|
||||
({{priceYearly.formattedAmount}}<sub class="per-year"> <span translate>/year</span></sub>)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plan-price plan-price-yearly">
|
||||
{{priceYearly.formattedMonthlyAmount}}<sub class="per-month"> <span translate>/month</span>{{#footnote}} (*){{/footnote}}</sub>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plan-price-yearly-per-year">
|
||||
<div>
|
||||
({{priceYearly.formattedAmount}}<sub class="per-year"> <span translate>/year</span></sub>)
|
||||
</div>
|
||||
</div>
|
||||
{{/priceYearly.formattedMonthlyAmount}}
|
||||
|
||||
{{#featureLabelsOn}}
|
||||
<p><i class="fas fa-check feature feature-on"></i>{{.}}</p>
|
||||
@@ -29,7 +33,11 @@
|
||||
{{/featureLabelsOff}}
|
||||
|
||||
<p class="text-center subscribe-wrapper">
|
||||
<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton">{{cfaLabel}}</a>
|
||||
<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton cfa-button">{{cfaLabel}}</a>
|
||||
|
||||
{{#learnMoreUrl}}
|
||||
<a id="learnMore-{{name}}" href="{{learnMoreUrl}}" class="button-link btn-white learnMoreButton cfa-button">Learn more</a>
|
||||
{{/learnMoreUrl}}
|
||||
</p>
|
||||
|
||||
{{#footnote}}<sub>(*) {{.}}</sub>{{/footnote}}
|
||||
|
||||
@@ -1,23 +1,91 @@
|
||||
<div id="plans-section" class="env-{{env}}">
|
||||
<style>
|
||||
.toggle-container {
|
||||
display: flex;
|
||||
border: 2px solid black;
|
||||
border-radius: 100px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
margin-top: 20px;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.toggle-option {
|
||||
flex: 1;
|
||||
padding: 10px 20px;
|
||||
text-align: center;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.inactive {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.toggle-container {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 title-box">
|
||||
<h1 translate class="text-center">
|
||||
Joplin Cloud <span class="frame-bg frame-bg-yellow">plans</span>
|
||||
Our synchronisation and sharing <span class="frame-bg frame-bg-yellow">solutions</span>
|
||||
</h1>
|
||||
<p translate class="text-center sub-title">
|
||||
<a href="https://joplincloud.com">Joplin Cloud</a> allows you to synchronise your notes across devices. It also lets you publish notes, and collaborate on notebooks with your friends, family or colleagues.
|
||||
Synchronise and share your notes with our range of plans.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-container" id="toggle">
|
||||
<div class="toggle-option active toggle-button-managed">Managed hosting</div>
|
||||
<div class="toggle-option inactive toggle-button-self">Self-hosting</div>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<div class="alert alert-danger alert-env-dev" role="alert" style='text-align: center; margin-top: 10px;'>
|
||||
To use this page please enable JavaScript!
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<div style="display: flex; justify-content: center; margin-top: 1.2em">
|
||||
<div class="row hosting-type-managed">
|
||||
<div class="col-12 title-box">
|
||||
<h1 translate class="text-center">
|
||||
Joplin Cloud
|
||||
</h1>
|
||||
<p translate class="text-center sub-title">
|
||||
<a href="https://joplincloud.com">Joplin Cloud</a> allows you to synchronise your notes across devices. It also lets you publish notes, and collaborate on notebooks with your friends, family or colleagues.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row hosting-type-self">
|
||||
<div class="col-12 title-box">
|
||||
<h1 translate class="text-center">
|
||||
Joplin Server Business
|
||||
</h1>
|
||||
<p translate class="text-center sub-title">
|
||||
Joplin Server Business is a synchronisation server that you can install on your own infrastructure, so that your data remains private and secure within your business.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: center; margin-top: 1.2em" class="hosting-type-managed">
|
||||
<div class="form-check form-check-inline">
|
||||
<input id="pay-monthly-radio" class="form-check-input" type="radio" name="pay-radio" checked value="monthly">
|
||||
<label translate style="font-weight: bold" class="form-check-label" for="pay-monthly-radio">
|
||||
@@ -46,7 +114,11 @@
|
||||
{{> plan}}
|
||||
{{/plans.teams}}
|
||||
|
||||
<p translate class="joplin-cloud-login-info">Already have a Joplin Cloud account? <a href="https://joplincloud.com">Login now</a></p>
|
||||
{{#plans.joplinServerBusiness}}
|
||||
{{> plan}}
|
||||
{{/plans.joplinServerBusiness}}
|
||||
|
||||
<p translate class="joplin-cloud-login-info hosting-type-managed">Already have a Joplin Cloud account? <a href="https://joplincloud.com">Login now</a></p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@@ -148,4 +220,30 @@
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<script>
|
||||
const setHostingType = (type) => {
|
||||
const other = type === 'managed' ? 'self' : 'managed';
|
||||
$('.toggle-button-' + type).addClass('active');
|
||||
$('.toggle-button-' + type).removeClass('inactive');
|
||||
$('.toggle-button-' + other).addClass('inactive');
|
||||
$('.toggle-button-' + other).removeClass('active');
|
||||
|
||||
$('.hosting-type-' + type).show();
|
||||
$('.hosting-type-' + other).hide();
|
||||
}
|
||||
|
||||
$('.toggle-button-managed').click((event) => {
|
||||
event.preventDefault();
|
||||
setHostingType('managed');
|
||||
});
|
||||
|
||||
$('.toggle-button-self').click((event) => {
|
||||
event.preventDefault();
|
||||
setHostingType('self');
|
||||
});
|
||||
|
||||
setHostingType('managed');
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
# Sponsors
|
||||
|
||||
<!-- SPONSORS-ORG -->
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://www.reddit.com/r/tiktokRise/"><img title="Tiktok Rise" width="256" src="https://joplinapp.org/images/sponsors/TiktokRise.jpg" alt="Tiktok Rise"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://homeworkguy.org/someone-to-take-my-online-class"><img title="someone to take my online class" width="256" src="https://joplinapp.org/images/sponsors/HomeworkGuy.png" alt="someone to take my online class"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
"vips.dev": {
|
||||
"platforms": ["aarch64-darwin"],
|
||||
},
|
||||
"nodejs": "latest",
|
||||
"nodejs": "23.8.0",
|
||||
"pkg-config": "latest",
|
||||
"darwin.apple_sdk.frameworks.Foundation": { // satisfies missing CoreText/CoreText.h
|
||||
// https://github.com/NixOS/nixpkgs/blob/master/pkgs/os-specific/darwin/apple-sdk/default.nix
|
||||
"version": "",
|
||||
"platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"python": "latest",
|
||||
"python": "3.13.1",
|
||||
"bat": "latest",
|
||||
"electron": {
|
||||
"version": "latest",
|
||||
|
||||
@@ -21,7 +21,7 @@ version: '2'
|
||||
services:
|
||||
|
||||
postgresql-master:
|
||||
image: 'bitnami/postgresql:16.6.0'
|
||||
image: 'bitnami/postgresql:17.3.0'
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
- POSTGRESQL_EXTRA_FLAGS=-c work_mem=100000 -c log_statement=all
|
||||
|
||||
postgresql-slave:
|
||||
image: 'bitnami/postgresql:16.6.0'
|
||||
image: 'bitnami/postgresql:17.3.0'
|
||||
ports:
|
||||
- '5433:5432'
|
||||
depends_on:
|
||||
|
||||
11
package.json
@@ -38,6 +38,7 @@
|
||||
"linter-precommit": "eslint --resolve-plugins-relative-to . --fix --ext .js --ext .jsx --ext .ts --ext .tsx",
|
||||
"linter": "eslint --resolve-plugins-relative-to . --fix --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
|
||||
"packageJsonLint": "node ./packages/tools/packageJsonLint.js",
|
||||
"syncFuzzer": "node ./packages/tools/fuzzer/sync-fuzzer.js",
|
||||
"postinstall": "husky && gulp build",
|
||||
"postPreReleasesToForum": "node ./packages/tools/postPreReleasesToForum",
|
||||
"publishAll": "git pull && yarn buildParallel && lerna version --yes --no-private --no-git-tag-version && gulp completePublishAll",
|
||||
@@ -65,7 +66,7 @@
|
||||
"watchWebsite": "nodemon --delay 1 --watch Assets/WebsiteAssets --watch packages/tools/website --watch packages/tools/website/utils --watch packages/doc-builder/build --ext md,ts,js,mustache,css,tsx,gif,png,svg --exec \"node packages/tools/website/build.js && http-server --port 8077 ../joplin-website/docs -a localhost\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@crowdin/cli": "3",
|
||||
"@crowdin/cli": "4",
|
||||
"@joplin/utils": "~2.12",
|
||||
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
@@ -79,13 +80,13 @@
|
||||
"eslint-plugin-react": "7.34.3",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "11.2.0",
|
||||
"glob": "10.4.5",
|
||||
"glob": "11.0.1",
|
||||
"gulp": "4.0.2",
|
||||
"husky": "9.1.7",
|
||||
"lerna": "3.22.1",
|
||||
"lint-staged": "15.4.3",
|
||||
"madge": "7.0.0",
|
||||
"npm-package-json-lint": "7.1.0",
|
||||
"lint-staged": "15.5.0",
|
||||
"madge": "8.0.0",
|
||||
"npm-package-json-lint": "8.0.0",
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -6,7 +6,7 @@ import Folder from '@joplin/lib/models/Folder';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Setting, { Env } from '@joplin/lib/models/Setting';
|
||||
import { reg } from '@joplin/lib/registry.js';
|
||||
import { dirname, fileExtension } from '@joplin/lib/path-utils';
|
||||
import { splitCommandString } from '@joplin/utils';
|
||||
@@ -16,6 +16,7 @@ import RevisionService from '@joplin/lib/services/RevisionService';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import setupCommand from './setupCommand';
|
||||
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import initializeCommandService from './utils/initializeCommandService';
|
||||
const { cliUtils } = require('./cli-utils.js');
|
||||
const Cache = require('@joplin/lib/Cache');
|
||||
const { splitCommandBatch } = require('@joplin/lib/string-utils');
|
||||
@@ -76,6 +77,12 @@ class Application extends BaseApplication {
|
||||
}
|
||||
}
|
||||
|
||||
public async loadItemOrFail(type: ModelType | 'folderOrNote', pattern: string) {
|
||||
const output = await this.loadItem(type, pattern);
|
||||
if (!output) throw new Error(_('Cannot find "%s".', pattern));
|
||||
return output;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public async loadItems(type: ModelType | 'folderOrNote', pattern: string, options: any = null): Promise<(FolderEntity | NoteEntity)[]> {
|
||||
if (type === 'folderOrNote') {
|
||||
@@ -412,8 +419,15 @@ class Application extends BaseApplication {
|
||||
|
||||
this.initRedux();
|
||||
|
||||
// Since the settings need to be loaded before the store is created, it will never
|
||||
// receive the SETTING_UPDATE_ALL even, which mean state.settings will not be
|
||||
// initialised. So we manually call dispatchUpdateAll() to force an update.
|
||||
Setting.dispatchUpdateAll();
|
||||
|
||||
if (!shim.sharpEnabled()) this.logger().warn('Sharp is disabled - certain image-related features will not be available');
|
||||
|
||||
initializeCommandService(this.store(), Setting.value('env') === Env.Dev);
|
||||
|
||||
// If we have some arguments left at this point, it's a command
|
||||
// so execute it.
|
||||
if (argv.length) {
|
||||
@@ -452,11 +466,6 @@ class Application extends BaseApplication {
|
||||
this.gui_.setLogger(this.logger());
|
||||
await this.gui_.start();
|
||||
|
||||
// Since the settings need to be loaded before the store is created, it will never
|
||||
// receive the SETTING_UPDATE_ALL even, which mean state.settings will not be
|
||||
// initialised. So we manually call dispatchUpdateAll() to force an update.
|
||||
Setting.dispatchUpdateAll();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
await refreshFolders((action: any) => this.store().dispatch(action), '');
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('command-done', () => {
|
||||
});
|
||||
|
||||
it('should make a note as "done"', async () => {
|
||||
const note = await Note.save({ title: 'hello', is_todo: 1, todo_completed: 0 });
|
||||
const note = await Note.save({ title: 'hello', is_todo: 1, todo_completed: 0, parent_id: '' });
|
||||
|
||||
const command = setupCommandForTesting(Command);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class Command extends BaseCommand {
|
||||
['-v, --verbose', 'More verbose output for the `target-status` command'],
|
||||
['-o, --output <directory>', 'Output directory'],
|
||||
['--retry-failed-items', 'Applies to `decrypt` command - retries decrypting items that previously could not be decrypted.'],
|
||||
['-f, --force', 'Do not ask for input on failure'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -67,7 +68,7 @@ class Command extends BaseCommand {
|
||||
this.stdout(line.join('\n'));
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error.code === 'masterKeyNotLoaded') {
|
||||
if (error.code === 'masterKeyNotLoaded' && !args.options.force) {
|
||||
const ok = await askForMasterKey(error);
|
||||
if (!ok) return;
|
||||
continue;
|
||||
|
||||
@@ -26,8 +26,7 @@ class Command extends BaseCommand {
|
||||
const pattern = args['notebook'];
|
||||
const force = args.options && args.options.force === true;
|
||||
|
||||
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
|
||||
if (!folder) throw new Error(_('Cannot find "%s".', pattern));
|
||||
const folder = await app().loadItemOrFail(BaseModel.TYPE_FOLDER, pattern);
|
||||
|
||||
const permanent = args.options?.permanent === true || !!folder.deleted_time;
|
||||
const ellipsizedFolderTitle = substrWithEllipsis(folder.title, 0, 32);
|
||||
|
||||
179
packages/app-cli/app/command-share.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import mockShareService, { ApiMock } from '@joplin/lib/testing/share/mockShareService';
|
||||
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { ShareInvitation, ShareUserStatus, StateShare } from '@joplin/lib/services/share/reducer';
|
||||
import app from './app';
|
||||
const Command = require('./command-share');
|
||||
|
||||
const setUpCommand = () => {
|
||||
const output: string[] = [];
|
||||
const stdout = (content: string) => {
|
||||
output.push(...content.split('\n'));
|
||||
};
|
||||
|
||||
const command = setupCommandForTesting(Command, stdout);
|
||||
return { command, output };
|
||||
};
|
||||
|
||||
const shareId = 'test-id';
|
||||
const defaultFolderShare: StateShare = {
|
||||
id: shareId,
|
||||
type: ModelType.Folder,
|
||||
folder_id: 'some-folder-id-here',
|
||||
note_id: undefined,
|
||||
master_key_id: undefined,
|
||||
user: {
|
||||
full_name: 'Test user',
|
||||
email: 'test@localhost',
|
||||
id: 'some-user-id',
|
||||
},
|
||||
};
|
||||
|
||||
const mockShareServiceForFolderSharing = (eventHandlerOverrides: Partial<ApiMock>&{ onExec?: undefined }) => {
|
||||
const invitations: ShareInvitation[] = [];
|
||||
|
||||
mockShareService({
|
||||
getShareInvitations: async () => ({
|
||||
items: invitations,
|
||||
}),
|
||||
getShares: async () => ({ items: [defaultFolderShare] }),
|
||||
getShareUsers: async (_id: string) => ({ items: [] }),
|
||||
postShareUsers: async (_id, _body) => { },
|
||||
postShares: async () => ({ id: shareId }),
|
||||
...eventHandlerOverrides,
|
||||
}, ShareService.instance(), app().store());
|
||||
|
||||
return {
|
||||
addInvitation: (invitation: Partial<ShareInvitation>) => {
|
||||
const defaultInvitation: ShareInvitation = {
|
||||
share: defaultFolderShare,
|
||||
id: 'some-invitation-id',
|
||||
master_key: undefined,
|
||||
status: ShareUserStatus.Waiting,
|
||||
can_read: 1,
|
||||
can_write: 1,
|
||||
};
|
||||
|
||||
invitations.push({ ...defaultInvitation, ...invitation });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
describe('command-share', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setupApplication();
|
||||
BaseItem.shareService_ = ShareService.instance();
|
||||
});
|
||||
|
||||
test('should allow adding a user to a share', async () => {
|
||||
const folder = await Folder.save({ title: 'folder1' });
|
||||
|
||||
let lastShareUserUpdate: unknown|null = null;
|
||||
mockShareServiceForFolderSharing({
|
||||
getShares: async () => {
|
||||
const isShared = !!lastShareUserUpdate;
|
||||
if (isShared) {
|
||||
return {
|
||||
items: [{ ...defaultFolderShare, folder_id: folder.id }],
|
||||
};
|
||||
} else {
|
||||
return { items: [] };
|
||||
}
|
||||
},
|
||||
// Called when a new user is added to a share
|
||||
postShareUsers: async (_id, body) => {
|
||||
lastShareUserUpdate = body;
|
||||
},
|
||||
});
|
||||
|
||||
const { command } = setUpCommand();
|
||||
|
||||
// Should share read-write by default
|
||||
await command.action({
|
||||
'command': 'add',
|
||||
'notebook': 'folder1',
|
||||
'user': 'test@localhost',
|
||||
options: {},
|
||||
});
|
||||
expect(lastShareUserUpdate).toMatchObject({
|
||||
email: 'test@localhost',
|
||||
can_write: 1,
|
||||
can_read: 1,
|
||||
});
|
||||
|
||||
// Should also support sharing as read only
|
||||
await command.action({
|
||||
'command': 'add',
|
||||
'notebook': 'folder1',
|
||||
'user': 'test2@localhost',
|
||||
options: {
|
||||
'read-only': true,
|
||||
},
|
||||
});
|
||||
expect(lastShareUserUpdate).toMatchObject({
|
||||
email: 'test2@localhost',
|
||||
can_write: 0,
|
||||
can_read: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'should list a single pending invitation',
|
||||
invitations: [{ id: 'test', status: ShareUserStatus.Waiting }],
|
||||
expectedOutput: [
|
||||
'Incoming shares:',
|
||||
'\tWaiting: Notebook some-folder-id-here from test@localhost',
|
||||
'All shared folders:',
|
||||
'\tNone',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
label: 'should list accepted invitations for non-existent folders with [None] as the folder title',
|
||||
invitations: [
|
||||
{ id: 'test2', status: ShareUserStatus.Accepted },
|
||||
],
|
||||
expectedOutput: [
|
||||
'Incoming shares:',
|
||||
'\tAccepted: Notebook [None] from test@localhost',
|
||||
'All shared folders:',
|
||||
'\tNone',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
label: 'should not list rejected shares',
|
||||
invitations: [
|
||||
{ id: 'test3', status: ShareUserStatus.Rejected },
|
||||
],
|
||||
expectedOutput: [
|
||||
'Incoming shares:',
|
||||
'\tNone',
|
||||
'All shared folders:',
|
||||
'\tNone',
|
||||
].join('\n'),
|
||||
},
|
||||
])('share invitations: $label', async ({ invitations, expectedOutput }) => {
|
||||
const mock = mockShareServiceForFolderSharing({});
|
||||
for (const invitation of invitations) {
|
||||
mock.addInvitation(invitation);
|
||||
}
|
||||
|
||||
await ShareService.instance().refreshShareInvitations();
|
||||
|
||||
const { command, output } = setUpCommand();
|
||||
await command.action({
|
||||
'command': 'list',
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(output.join('\n')).toBe(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
297
packages/app-cli/app/command-share.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import { ShareUserStatus } from '@joplin/lib/services/share/reducer';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import invitationRespond from '@joplin/lib/services/share/invitationRespond';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { substrWithEllipsis } from '@joplin/lib/string-utils';
|
||||
|
||||
const logger = Logger.create('command-share');
|
||||
|
||||
type Args = {
|
||||
command: string;
|
||||
// eslint-disable-next-line id-denylist -- The "notebook" identifier comes from the UI.
|
||||
notebook?: string;
|
||||
user?: string;
|
||||
options: {
|
||||
'read-only'?: boolean;
|
||||
json?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const folderTitle = (folder: FolderEntity|null) => {
|
||||
return folder ? substrWithEllipsis(folder.title, 0, 32) : _('[None]');
|
||||
};
|
||||
|
||||
const getShareState = () => app().store().getState().shareService;
|
||||
const getShareFromFolderId = (folderId: string) => {
|
||||
const shareState = getShareState();
|
||||
const allShares = shareState.shares;
|
||||
const share = allShares.find(share => share.folder_id === folderId);
|
||||
return share;
|
||||
};
|
||||
|
||||
const getShareUsers = (folderId: string) => {
|
||||
const share = getShareFromFolderId(folderId);
|
||||
if (!share) {
|
||||
throw new Error(`No share found for folder ${folderId}`);
|
||||
}
|
||||
return getShareState().shareUsers[share.id];
|
||||
};
|
||||
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public usage() {
|
||||
return 'share <command> [notebook] [user]';
|
||||
}
|
||||
|
||||
public description() {
|
||||
return [
|
||||
_('Shares or unshares the specified [notebook] with [user]. Requires Joplin Cloud or Joplin Server.'),
|
||||
_('Commands: `add`, `remove`, `list`, `delete`, `accept`, `leave`, and `reject`.'),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
public options() {
|
||||
return [
|
||||
['--read-only', _('Don\'t allow the share recipient to write to the shared notebook. Valid only for the `add` subcommand.')],
|
||||
['-f, --force', _('Do not ask for user confirmation.')],
|
||||
['--json', _('Prefer JSON output.')],
|
||||
];
|
||||
}
|
||||
|
||||
public async action(args: Args) {
|
||||
const commandShareAdd = async (folder: FolderEntity, email: string) => {
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
|
||||
const share = await ShareService.instance().shareFolder(folder.id);
|
||||
|
||||
const permissions = {
|
||||
can_read: 1,
|
||||
can_write: args.options['read-only'] ? 0 : 1,
|
||||
};
|
||||
logger.debug('Sharing folder', folder.id, 'with', email, 'permissions=', permissions);
|
||||
|
||||
await ShareService.instance().addShareRecipient(share.id, share.master_key_id, email, permissions);
|
||||
|
||||
await ShareService.instance().refreshShares();
|
||||
await ShareService.instance().refreshShareUsers(share.id);
|
||||
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
};
|
||||
|
||||
const commandShareRemove = async (folder: FolderEntity, email: string) => {
|
||||
await ShareService.instance().refreshShares();
|
||||
|
||||
const share = getShareFromFolderId(folder.id);
|
||||
if (!share) {
|
||||
throw new Error(`No share found for folder ${folder.id}`);
|
||||
}
|
||||
|
||||
await ShareService.instance().refreshShareUsers(share.id);
|
||||
|
||||
const shareUsers = getShareUsers(folder.id);
|
||||
if (!shareUsers) {
|
||||
throw new Error(`No share found for folder ${folder.id}`);
|
||||
}
|
||||
|
||||
const targetUser = shareUsers.find(user => user.user?.email === email);
|
||||
if (!targetUser) {
|
||||
throw new Error(`No recipient found with email ${email}`);
|
||||
}
|
||||
|
||||
await ShareService.instance().deleteShareRecipient(targetUser.id);
|
||||
this.stdout(_('Removed %s from share.', targetUser.user.email));
|
||||
};
|
||||
|
||||
const commandShareList = async () => {
|
||||
let folder = null;
|
||||
if (args.notebook) {
|
||||
folder = await app().loadItemOrFail(ModelType.Folder, args.notebook);
|
||||
}
|
||||
|
||||
await ShareService.instance().maintenance();
|
||||
|
||||
if (folder) {
|
||||
const share = getShareFromFolderId(folder.id);
|
||||
await ShareService.instance().refreshShareUsers(share.id);
|
||||
|
||||
const shareUsers = getShareUsers(folder.id);
|
||||
const output = {
|
||||
folderTitle: folderTitle(folder),
|
||||
sharedWith: (shareUsers ?? []).map(user => ({
|
||||
email: user.user.email,
|
||||
readOnly: user.can_read && !user.can_write,
|
||||
})),
|
||||
};
|
||||
|
||||
if (args.options.json) {
|
||||
this.stdout(JSON.stringify(output));
|
||||
} else {
|
||||
this.stdout(_('Folder "%s" is shared with:', output.folderTitle));
|
||||
for (const user of output.sharedWith) {
|
||||
this.stdout(`\t${user.email}\t${user.readOnly ? _('(Read-only)') : ''}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const shareState = getShareState();
|
||||
const output = {
|
||||
invitations: shareState.shareInvitations.map(invitation => ({
|
||||
accepted: invitation.status === ShareUserStatus.Accepted,
|
||||
waiting: invitation.status === ShareUserStatus.Waiting,
|
||||
rejected: invitation.status === ShareUserStatus.Rejected,
|
||||
folderId: invitation.share.folder_id,
|
||||
fromUser: {
|
||||
email: invitation.share.user?.email,
|
||||
},
|
||||
})),
|
||||
shares: shareState.shares.map(share => ({
|
||||
isFolder: !!share.folder_id,
|
||||
isNote: !!share.note_id,
|
||||
itemId: share.folder_id ?? share.note_id,
|
||||
fromUser: {
|
||||
email: share.user?.email,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
if (args.options.json) {
|
||||
this.stdout(JSON.stringify(output));
|
||||
} else {
|
||||
this.stdout(_('Incoming shares:'));
|
||||
let loggedInvitation = false;
|
||||
for (const invitation of output.invitations) {
|
||||
let message;
|
||||
if (invitation.waiting) {
|
||||
message = _('Waiting: Notebook %s from %s', invitation.folderId, invitation.fromUser.email);
|
||||
}
|
||||
if (invitation.accepted) {
|
||||
const folder = await Folder.load(invitation.folderId);
|
||||
message = _('Accepted: Notebook %s from %s', folderTitle(folder), invitation.fromUser.email);
|
||||
}
|
||||
|
||||
if (message) {
|
||||
this.stdout(`\t${message}`);
|
||||
loggedInvitation = true;
|
||||
}
|
||||
}
|
||||
if (!loggedInvitation) {
|
||||
this.stdout(`\t${_('None')}`);
|
||||
}
|
||||
|
||||
this.stdout(_('All shared folders:'));
|
||||
if (output.shares.length) {
|
||||
for (const share of output.shares) {
|
||||
let title;
|
||||
if (share.isFolder) {
|
||||
title = folderTitle(await Folder.load(share.itemId));
|
||||
} else {
|
||||
title = share.itemId;
|
||||
}
|
||||
|
||||
if (share.fromUser?.email) {
|
||||
this.stdout(`\t${_('%s from %s', title, share.fromUser?.email)}`);
|
||||
} else {
|
||||
this.stdout(`\t${title} - ${share.itemId}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.stdout(`\t${_('None')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const commandShareAcceptOrReject = async (folderId: string, accept: boolean) => {
|
||||
await ShareService.instance().maintenance();
|
||||
|
||||
const shareState = getShareState();
|
||||
const invitations = shareState.shareInvitations.filter(invitation => {
|
||||
return invitation.share.folder_id === folderId && invitation.status === ShareUserStatus.Waiting;
|
||||
});
|
||||
if (invitations.length === 0) throw new Error('No such invitation found');
|
||||
|
||||
// If there are multiple invitations for the same folder, stop early to avoid
|
||||
// accepting the wrong invitation.
|
||||
if (invitations.length > 1) throw new Error('Multiple invitations found with the same ID');
|
||||
|
||||
const invitation = invitations[0];
|
||||
|
||||
this.stdout(accept ? _('Accepting share...') : _('Rejecting share...'));
|
||||
await invitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, accept);
|
||||
};
|
||||
|
||||
const commandShareAccept = (folderId: string) => (
|
||||
commandShareAcceptOrReject(folderId, true)
|
||||
);
|
||||
|
||||
const commandShareReject = (folderId: string) => (
|
||||
commandShareAcceptOrReject(folderId, false)
|
||||
);
|
||||
|
||||
const commandShareDelete = async (folder: FolderEntity) => {
|
||||
const force = args.options.force;
|
||||
const ok = force ? true : await this.prompt(
|
||||
_('Unshare notebook "%s"? This may cause other users to lose access to the notebook.', folderTitle(folder)),
|
||||
{ booleanAnswerDefault: 'n' },
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
logger.info('Unsharing folder', folder.id);
|
||||
await ShareService.instance().unshareFolder(folder.id);
|
||||
await reg.scheduleSync();
|
||||
};
|
||||
|
||||
if (args.command === 'add' || args.command === 'remove' || args.command === 'delete') {
|
||||
if (!args.notebook) throw new Error('[notebook] is required');
|
||||
const folder = await app().loadItemOrFail(ModelType.Folder, args.notebook);
|
||||
|
||||
if (args.command === 'delete') {
|
||||
return commandShareDelete(folder);
|
||||
} else {
|
||||
if (!args.user) throw new Error('[user] is required');
|
||||
|
||||
const email = args.user;
|
||||
if (args.command === 'add') {
|
||||
return commandShareAdd(folder, email);
|
||||
} else if (args.command === 'remove') {
|
||||
return commandShareRemove(folder, email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.command === 'leave') {
|
||||
const folder = args.notebook ? await app().loadItemOrFail(ModelType.Folder, args.notebook) : null;
|
||||
|
||||
await ShareService.instance().maintenance();
|
||||
|
||||
return CommandService.instance().execute(
|
||||
'leaveSharedFolder', folder?.id, { force: args.options.force },
|
||||
);
|
||||
}
|
||||
|
||||
if (args.command === 'list') {
|
||||
return commandShareList();
|
||||
}
|
||||
|
||||
if (args.command === 'accept') {
|
||||
return commandShareAccept(args.notebook);
|
||||
}
|
||||
|
||||
if (args.command === 'reject') {
|
||||
return commandShareReject(args.notebook);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown subcommand: ${args.command}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Command;
|
||||
@@ -17,6 +17,7 @@ import { pathExists, writeFile } from 'fs-extra';
|
||||
import { checkIfLoginWasSuccessful, generateApplicationConfirmUrl } from '@joplin/lib/services/joplinCloudUtils';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { uuidgen } from '@joplin/lib/uuid';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
|
||||
const logger = Logger.create('command-sync');
|
||||
|
||||
@@ -230,6 +231,10 @@ class Command extends BaseCommand {
|
||||
return cleanUp();
|
||||
}
|
||||
|
||||
// Refresh share invitations -- if running without a GUI, some of the
|
||||
// maintenance tasks may otherwise be skipped.
|
||||
await ShareService.instance().maintenance();
|
||||
|
||||
this.stdout(_('Starting synchronisation...'));
|
||||
|
||||
const contextKey = `sync.${this.syncTargetId_}.context`;
|
||||
|
||||
@@ -22,7 +22,7 @@ const Setting = require('@joplin/lib/models/Setting').default;
|
||||
const Revision = require('@joplin/lib/models/Revision').default;
|
||||
const Logger = require('@joplin/utils/Logger').default;
|
||||
const FsDriverNode = require('@joplin/lib/fs-driver-node').default;
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
const shimInitCli = require('./utils/shimInitCli').default;
|
||||
const shim = require('@joplin/lib/shim').default;
|
||||
const { _ } = require('@joplin/lib/locale');
|
||||
const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default;
|
||||
@@ -73,7 +73,7 @@ function appVersion() {
|
||||
return p.version;
|
||||
}
|
||||
|
||||
shimInit({ sharp, keytar, appVersion, nodeSqlite });
|
||||
shimInitCli({ sharp, keytar, appVersion, nodeSqlite });
|
||||
|
||||
const logger = new Logger();
|
||||
Logger.initializeGlobalLogger(logger);
|
||||
|
||||
14
packages/app-cli/app/utils/initializeCommandService.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
|
||||
import libCommands from '@joplin/lib/commands/index';
|
||||
import { State } from '@joplin/lib/reducer';
|
||||
import { Store } from 'redux';
|
||||
|
||||
export default function initializeCommandService(store: Store<State>, devMode: boolean) {
|
||||
CommandService.instance().initialize(store, devMode, stateToWhenClauseContext);
|
||||
|
||||
for (const command of libCommands) {
|
||||
CommandService.instance().registerDeclaration(command.declaration);
|
||||
CommandService.instance().registerRuntime(command.declaration.name, command.runtime());
|
||||
}
|
||||
}
|
||||
32
packages/app-cli/app/utils/shimInitCli.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import shim, { ShowMessageBoxOptions } from '@joplin/lib/shim';
|
||||
import type { ShimInitOptions } from '@joplin/lib/shim-init-node';
|
||||
import app from '../app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
|
||||
const shimInitCli = (options: ShimInitOptions) => {
|
||||
shimInit(options);
|
||||
|
||||
shim.showMessageBox = async (message: string, options: ShowMessageBoxOptions) => {
|
||||
const gui = app()?.gui();
|
||||
let answers = options.buttons ?? [_('Ok'), _('Cancel')];
|
||||
|
||||
if (options.type === 'error' || options.type === 'info') {
|
||||
answers = [];
|
||||
}
|
||||
|
||||
message += answers.length ? `(${answers.join(', ')})` : '';
|
||||
|
||||
const answer = await gui.prompt(options.title ?? '', `${message} `, { answers });
|
||||
|
||||
if (answers.includes(answer)) {
|
||||
return answers.indexOf(answer);
|
||||
} else if (answer) {
|
||||
return answers.findIndex(a => a.startsWith(answer));
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
};
|
||||
|
||||
export default shimInitCli;
|
||||
@@ -15,4 +15,7 @@ export const setupApplication = async () => {
|
||||
// such notebook.
|
||||
await Folder.save({ title: 'default' });
|
||||
await app().refreshCurrentFolder();
|
||||
|
||||
// Some tests also need access to the Redux store
|
||||
app().initRedux();
|
||||
};
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"node-rsa": "1.1.1",
|
||||
"open": "8.4.2",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"redux": "4.2.1",
|
||||
"server-destroy": "1.0.1",
|
||||
"sharp": "0.33.5",
|
||||
"sprintf-js": "1.1.3",
|
||||
@@ -72,7 +73,7 @@
|
||||
"@joplin/tools": "~3.4",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "18.19.67",
|
||||
"@types/node": "18.19.86",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
|
||||
1
packages/app-cli/tests/html_to_md/comments_in_style.html
Normal file
@@ -0,0 +1 @@
|
||||
<p><span style="/* Comment */ text-decoration: underline;">Test</span>. In the past, <span style="font-size: auto;/* Test! */">comments</span> in CSS have caused issues.</p>
|
||||
1
packages/app-cli/tests/html_to_md/comments_in_style.md
Normal file
@@ -0,0 +1 @@
|
||||
<ins>Test</ins>. In the past, comments in CSS have caused issues.
|
||||
@@ -343,6 +343,14 @@ export default class ElectronAppWrapper {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const sendWindowFocused = (focusedWebContents: WebContents) => {
|
||||
const joplinId = this.windowIdFromWebContents(focusedWebContents);
|
||||
|
||||
if (joplinId !== null) {
|
||||
this.win_.webContents.send('window-focused', joplinId);
|
||||
}
|
||||
};
|
||||
|
||||
const addWindowEventHandlers = (webContents: WebContents) => {
|
||||
// will-frame-navigate is fired by clicking on a link within the BrowserWindow.
|
||||
webContents.on('will-frame-navigate', event => {
|
||||
@@ -376,13 +384,10 @@ export default class ElectronAppWrapper {
|
||||
addWindowEventHandlers(event.webContents);
|
||||
});
|
||||
|
||||
webContents.on('focus', () => {
|
||||
const joplinId = this.windowIdFromWebContents(webContents);
|
||||
|
||||
if (joplinId !== null) {
|
||||
this.win_.webContents.send('window-focused', joplinId);
|
||||
}
|
||||
});
|
||||
const onFocus = () => {
|
||||
sendWindowFocused(webContents);
|
||||
};
|
||||
webContents.on('focus', onFocus);
|
||||
};
|
||||
addWindowEventHandlers(this.win_.webContents);
|
||||
|
||||
@@ -454,6 +459,10 @@ export default class ElectronAppWrapper {
|
||||
this.win_.close();
|
||||
}
|
||||
});
|
||||
|
||||
if (window.isFocused()) {
|
||||
sendWindowFocused(window.webContents);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
|
||||
@@ -7,7 +7,6 @@ import * as editAlarm from './editAlarm';
|
||||
import * as exportPdf from './exportPdf';
|
||||
import * as gotoAnything from './gotoAnything';
|
||||
import * as hideModalMessage from './hideModalMessage';
|
||||
import * as leaveSharedFolder from './leaveSharedFolder';
|
||||
import * as linkToNote from './linkToNote';
|
||||
import * as moveToFolder from './moveToFolder';
|
||||
import * as newFolder from './newFolder';
|
||||
@@ -56,7 +55,6 @@ const index: any[] = [
|
||||
exportPdf,
|
||||
gotoAnything,
|
||||
hideModalMessage,
|
||||
leaveSharedFolder,
|
||||
linkToNote,
|
||||
moveToFolder,
|
||||
newFolder,
|
||||
|
||||
@@ -10,6 +10,7 @@ window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
|
||||
onCommitFiberUnmount: function() {},
|
||||
};
|
||||
|
||||
require('./utils/sourceMapSetup');
|
||||
const app = require('./app').default;
|
||||
const Folder = require('@joplin/lib/models/Folder').default;
|
||||
const Resource = require('@joplin/lib/models/Resource').default;
|
||||
@@ -39,32 +40,6 @@ window.React = React;
|
||||
|
||||
|
||||
const main = async () => {
|
||||
if (bridge().env() === 'dev') {
|
||||
const newConsole = function(oldConsole) {
|
||||
const output = {};
|
||||
const fnNames = ['assert', 'clear', 'context', 'count', 'countReset', 'debug', 'dir', 'dirxml', 'error', 'group', 'groupCollapsed', 'groupEnd', 'info', 'log', 'memory', 'profile', 'profileEnd', 'table', 'time', 'timeEnd', 'timeLog', 'timeStamp', 'trace', 'warn'];
|
||||
for (const fnName of fnNames) {
|
||||
if (fnName === 'warn') {
|
||||
output.warn = function(...text) {
|
||||
const s = [...text].join('');
|
||||
// React spams the console with walls of warnings even outside of strict mode, and even after having renamed
|
||||
// unsafe methods to UNSAFE_xxxx, so we need to hack the console to remove them...
|
||||
if (s.indexOf('Warning: componentWillReceiveProps has been renamed, and is not recommended for use') === 0) return;
|
||||
if (s.indexOf('Warning: componentWillUpdate has been renamed, and is not recommended for use.') === 0) return;
|
||||
oldConsole.warn(...text);
|
||||
};
|
||||
} else {
|
||||
output[fnName] = function(...text) {
|
||||
return oldConsole[fnName](...text);
|
||||
};
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}(window.console);
|
||||
|
||||
window.console = newConsole;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(`Environment: ${bridge().env()}`);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// This is the basic initialization for the Electron MAIN process
|
||||
|
||||
require('./utils/sourceMapSetup');
|
||||
const electronApp = require('electron').app;
|
||||
require('@electron/remote/main').initialize();
|
||||
const ElectronAppWrapper = require('./ElectronAppWrapper').default;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"electronRebuild": "gulp electronRebuild",
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
|
||||
"start": "gulp before-start && electron . --env dev --log-level debug --open-dev-tools --no-welcome",
|
||||
"start": "gulp before-start && JOPLIN_SOURCE_MAP_ENABLED=1 electron . --env dev --log-level debug --open-dev-tools --no-welcome",
|
||||
"test": "jest",
|
||||
"test-ui": "gulp before-start && playwright test",
|
||||
"test-ci": "yarn test",
|
||||
@@ -132,8 +132,8 @@
|
||||
"devDependencies": {
|
||||
"7zip-bin": "5.2.0",
|
||||
"@axe-core/playwright": "4.10.1",
|
||||
"@electron/notarize": "2.3.2",
|
||||
"@electron/rebuild": "3.6.0",
|
||||
"@electron/notarize": "2.5.0",
|
||||
"@electron/rebuild": "3.7.1",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
"@joeattardi/emoji-button": "4.6.4",
|
||||
"@joplin/default-plugins": "~3.4",
|
||||
@@ -147,9 +147,9 @@
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/mustache": "4.2.5",
|
||||
"@types/node": "18.19.67",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/node": "18.19.86",
|
||||
"@types/react": "18.3.20",
|
||||
"@types/react-dom": "18.3.6",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"@types/tesseract.js": "2.0.0",
|
||||
@@ -162,11 +162,11 @@
|
||||
"debounce": "1.2.1",
|
||||
"electron": "35.5.1",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-updater": "6.2.1",
|
||||
"electron-updater": "6.6.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"esbuild": "^0.25.3",
|
||||
"formatcoords": "1.1.3",
|
||||
"glob": "10.4.5",
|
||||
"glob": "11.0.1",
|
||||
"gulp": "4.0.2",
|
||||
"highlight.js": "11.11.1",
|
||||
"immer": "9.0.21",
|
||||
@@ -184,11 +184,11 @@
|
||||
"node-rsa": "1.1.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"pretty-bytes": "5.6.0",
|
||||
"re-resizable": "6.9.17",
|
||||
"re-resizable": "6.11.2",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-redux": "8.1.3",
|
||||
"react-select": "5.8.0",
|
||||
"react-select": "5.10.1",
|
||||
"react-test-renderer": "18.3.1",
|
||||
"react-toggle-button": "2.2.0",
|
||||
"react-tooltip": "4.5.1",
|
||||
@@ -196,6 +196,7 @@
|
||||
"reselect": "4.1.8",
|
||||
"roboto-fontface": "0.10.0",
|
||||
"smalltalk": "2.5.1",
|
||||
"source-map-support": "0.5.21",
|
||||
"styled-components": "5.3.11",
|
||||
"styled-system": "5.1.5",
|
||||
"taboverride": "4.0.3",
|
||||
|
||||
@@ -39,6 +39,12 @@
|
||||
|
||||
# ./runForTesting.sh 1 createTeams,createData,resetTeam,sync && ./runForTesting.sh 2 resetTeam,sync && ./runForTesting.sh 1
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# User 1 shares a folder with user 2
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
# ./runForTesting.sh 1 createUsers,createData,reset,shareWithUser2,sync && ./runForTesting.sh 2 reset,sync && ./runForTesting.sh 1
|
||||
|
||||
# ----------------------------------------------------------------------------------
|
||||
# Testing the CLI app with commands:
|
||||
# ----------------------------------------------------------------------------------
|
||||
@@ -123,6 +129,13 @@ do
|
||||
echo 'use "shared"' >> "$CMD_FILE"
|
||||
echo 'mknote "note 1"' >> "$CMD_FILE"
|
||||
echo 'mknote "note 2"' >> "$CMD_FILE"
|
||||
echo 'mkbook --parent "shared" "sub"' >> "$CMD_FILE"
|
||||
echo 'use "sub"' >> "$CMD_FILE"
|
||||
echo 'mknote "note 3"' >> "$CMD_FILE"
|
||||
|
||||
elif [[ $CMD == "shareWithUser2" ]]; then
|
||||
|
||||
echo 'share add "shared" user2@example.com' >> "$CMD_FILE"
|
||||
|
||||
elif [[ $CMD == "reset" ]]; then
|
||||
|
||||
@@ -169,6 +182,12 @@ do
|
||||
fi
|
||||
done
|
||||
|
||||
echo '----------------------------------------------------'
|
||||
echo 'Running commands:'
|
||||
echo '';
|
||||
cat "$CMD_FILE"
|
||||
echo '----------------------------------------------------'
|
||||
|
||||
cd "$ROOT_DIR/packages/app-cli"
|
||||
yarn start --profile "$PROFILE_DIR" batch "$CMD_FILE"
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { filename, toForwardSlashes } from '@joplin/utils/path';
|
||||
import * as esbuild from 'esbuild';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { dirname, join, relative } from 'path';
|
||||
|
||||
const baseDir = dirname(__dirname);
|
||||
const baseNodeModules = join(baseDir, 'node_modules');
|
||||
|
||||
// Note: Roughly based on js-draw's use of esbuild:
|
||||
// https://github.com/personalizedrefrigerator/js-draw/blob/6fe6d6821402a08a8d17f15a8f48d95e5d7b084f/packages/build-tool/src/BundledFile.ts#L64
|
||||
const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSizeStats: boolean) => {
|
||||
@@ -12,9 +15,10 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
|
||||
outfile: `${filename(entryPoint)}.bundle.js`,
|
||||
bundle: true,
|
||||
minify: true,
|
||||
keepNames: true,
|
||||
keepNames: true, // Preserve original function names -- useful for debugging
|
||||
format: 'iife', // Immediately invoked function expression
|
||||
sourcemap: true,
|
||||
sourcesContent: false, // Do not embed full source file content in the .map file
|
||||
metafile: computeFileSizeStats,
|
||||
platform: 'node',
|
||||
target: ['node20.0'],
|
||||
@@ -27,8 +31,6 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
|
||||
name: 'joplin--relative-imports-for-externals',
|
||||
setup: build => {
|
||||
const externalRegex = /^(.*\.node|sqlite3|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
|
||||
const baseDir = dirname(__dirname);
|
||||
const baseNodeModules = join(baseDir, 'node_modules');
|
||||
build.onResolve({ filter: externalRegex }, args => {
|
||||
// Electron packages don't need relative requires
|
||||
if (args.path === 'electron' || args.path.startsWith('electron/')) {
|
||||
@@ -65,8 +67,6 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
|
||||
// Rewrite imports to prefer .js files to .ts. Otherwise, certain files are duplicated in the final bundle
|
||||
name: 'joplin--prefer-js-imports',
|
||||
setup: build => {
|
||||
const baseDir = dirname(__dirname);
|
||||
const baseNodeModules = join(baseDir, 'node_modules');
|
||||
// Rewrite all relative imports
|
||||
build.onResolve({ filter: /^\./ }, args => {
|
||||
try {
|
||||
@@ -89,6 +89,31 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'joplin--smaller-source-map-size',
|
||||
setup: build => {
|
||||
// Exclude dependencies from node_modules. This significantly reduces the size of the
|
||||
// source map, improving startup performance.
|
||||
//
|
||||
// See https://github.com/evanw/esbuild/issues/1685#issuecomment-944916409
|
||||
// and https://github.com/evanw/esbuild/issues/4130
|
||||
const emptyMapData = Buffer.from(
|
||||
JSON.stringify({ version: 3, sources: [null], mappings: 'AAAA' }),
|
||||
'utf-8',
|
||||
).toString('base64');
|
||||
const emptyMapUrl = `data:application/json;base64,${emptyMapData}`;
|
||||
|
||||
build.onLoad({ filter: /node_modules.*js$/ }, args => {
|
||||
return {
|
||||
contents: [
|
||||
readFileSync(args.path, 'utf8'),
|
||||
`//# sourceMappingURL=${emptyMapUrl}`,
|
||||
].join('\n'),
|
||||
loader: 'default',
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
6
packages/app-desktop/utils/sourceMapSetup.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
// source-map-support can add 1-3 seconds to the application startup
|
||||
// time -- disable it unless requested:
|
||||
if (process.env.JOPLIN_SOURCE_MAP_ENABLED) {
|
||||
require('source-map-support').install();
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import net.cozic.joplin.audio.SpeechToTextPackage
|
||||
import net.cozic.joplin.versioninfo.SystemVersionInformationPackage
|
||||
import net.cozic.joplin.share.SharePackage
|
||||
import net.cozic.joplin.ssl.SslPackage
|
||||
import net.cozic.joplin.textinput.TextInputPackage
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(this, object : DefaultReactNativeHost(this) {
|
||||
@@ -27,7 +26,6 @@ class MainApplication : Application(), ReactApplication {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
add(SharePackage())
|
||||
add(SslPackage())
|
||||
add(TextInputPackage())
|
||||
add(SystemVersionInformationPackage())
|
||||
add(SpeechToTextPackage())
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package net.cozic.joplin.textinput;
|
||||
|
||||
import android.text.Selection;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.facebook.react.bridge.NativeModule;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ReadableArray;
|
||||
import com.facebook.react.uimanager.ViewManager;
|
||||
import com.facebook.react.views.textinput.ReactEditText;
|
||||
import com.facebook.react.views.textinput.ReactTextInputManager;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* This class provides a workaround for <a href="https://github.com/facebook/react-native/issues/29911">
|
||||
* https://github.com/facebook/react-native/issues/29911</a>
|
||||
*
|
||||
* The reason the editor is scrolled seems to be due to this block in
|
||||
* <pre>android.widget.Editor#onFocusChanged:</pre>
|
||||
*
|
||||
* <pre>
|
||||
* // The DecorView does not have focus when the 'Done' ExtractEditText button is
|
||||
* // pressed. Since it is the ViewAncestor's mView, it requests focus before
|
||||
* // ExtractEditText clears focus, which gives focus to the ExtractEditText.
|
||||
* // This special case ensure that we keep current selection in that case.
|
||||
* // It would be better to know why the DecorView does not have focus at that time.
|
||||
* if (((mTextView.isInExtractedMode()) || mSelectionMoved)
|
||||
* && selStart >= 0 && selEnd >= 0) {
|
||||
* Selection.setSelection((Spannable)mTextView.getText(),selStart,selEnd);
|
||||
* }
|
||||
* </pre>
|
||||
* When using native Android TextView mSelectionMoved is false so this block is skipped,
|
||||
* with RN however it's true and this is where the scrolling comes from.
|
||||
*
|
||||
* The below workaround resets the selection before a focus event is passed on to the native component.
|
||||
* This way when the above condition is reached <pre>selStart == selEnd == -1</pre> and no scrolling
|
||||
* happens.
|
||||
*/
|
||||
public class TextInputPackage implements com.facebook.react.ReactPackage {
|
||||
@NonNull
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) {
|
||||
return Collections.singletonList(new ReactTextInputManager() {
|
||||
@Override
|
||||
public void receiveCommand(ReactEditText reactEditText, String commandId, @Nullable ReadableArray args) {
|
||||
if ("focus".equals(commandId) || "focusTextInput".equals(commandId)) {
|
||||
Selection.removeSelection(reactEditText.getText());
|
||||
}
|
||||
super.receiveCommand(reactEditText, commandId, args);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -136,17 +136,16 @@ const ActionButtons: React.FC<Props> = props => {
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
return <>
|
||||
<View style={styles.buttonRowContainerTop}>
|
||||
<IconButton
|
||||
{props.onCancelPhoto && <IconButton
|
||||
themeId={props.themeId}
|
||||
iconName='ionicon arrow-back'
|
||||
containerStyle={styles.buttonContainer}
|
||||
iconStyle={styles.buttonContent}
|
||||
onPress={props.onCancelPhoto}
|
||||
description={_('Back')}
|
||||
/>
|
||||
/>}
|
||||
</View>
|
||||
{props.cameraReady ? cameraActions : <ActivityIndicator/>}
|
||||
</>;
|
||||
|
||||
@@ -9,10 +9,12 @@ import { TextInput } from 'react-native';
|
||||
const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
|
||||
useImperativeHandle(ref, () => ({
|
||||
takePictureAsync: async () => {
|
||||
const path = `${shim.fsDriver().getCacheDirectoryPath()}/test-photo.svg`;
|
||||
const parentDir = shim.fsDriver().getCacheDirectoryPath();
|
||||
await shim.fsDriver().mkdir(parentDir);
|
||||
const path = `${parentDir}/test-photo.svg`;
|
||||
await shim.fsDriver().writeFile(
|
||||
path,
|
||||
`<svg viewBox="0 0 232 78" width="232" height="78" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
|
||||
`<svg viewBox="0 -70 232 78" width="232" height="78" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
|
||||
<text style="font-family: serif; font-size: 104px; fill: rgb(128, 51, 128);">Test!</text>
|
||||
</svg>`,
|
||||
'utf8',
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
// Use the mock camera component on web -- for now, the default Camera
|
||||
// component is Android/iOS only
|
||||
import Camera from './index.jest';
|
||||
export default Camera;
|
||||
@@ -4,6 +4,7 @@ import { CameraResult } from './types';
|
||||
import { fireEvent, render, screen } from '../../utils/testing/testingLibrary';
|
||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
import TestProviderStack from '../testing/TestProviderStack';
|
||||
import { acceptCameraPermission, rejectCameraPermission, setQrCodeData, startCamera } from './utils/testing';
|
||||
|
||||
interface WrapperProps {
|
||||
onPhoto?: (result: CameraResult)=> void;
|
||||
@@ -24,26 +25,6 @@ const CameraViewWrapper: React.FC<WrapperProps> = props => {
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
const rejectCameraPermission = () => {
|
||||
const rejectPermissionButton = screen.getByRole('button', { name: 'Reject permission' });
|
||||
fireEvent.press(rejectPermissionButton);
|
||||
};
|
||||
|
||||
const acceptCameraPermission = () => {
|
||||
const acceptPermissionButton = screen.getByRole('button', { name: 'Accept permission' });
|
||||
fireEvent.press(acceptPermissionButton);
|
||||
};
|
||||
|
||||
const startCamera = () => {
|
||||
const startCameraButton = screen.getByRole('button', { name: 'On camera ready' });
|
||||
fireEvent.press(startCameraButton);
|
||||
};
|
||||
|
||||
const setQrCodeData = (data: string) => {
|
||||
const qrCodeDataInput = screen.getByPlaceholderText('QR code data');
|
||||
fireEvent.changeText(qrCodeDataInput, data);
|
||||
};
|
||||
|
||||
describe('CameraView', () => {
|
||||
test('should hide permissions error if camera permission is granted', async () => {
|
||||
const view = render(<CameraViewWrapper/>);
|
||||
@@ -85,3 +66,4 @@ describe('CameraView', () => {
|
||||
view.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { Text, StyleSheet, Linking, View, Platform, useWindowDimensions } from 'react-native';
|
||||
@@ -10,15 +10,15 @@ import ActionButtons from './ActionButtons';
|
||||
import { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { LinkButton, PrimaryButton } from '../buttons';
|
||||
import BackButtonService from '../../services/BackButtonService';
|
||||
import { themeStyle } from '../global-style';
|
||||
import fitRectIntoBounds from './utils/fitRectIntoBounds';
|
||||
import useBarcodeScanner from './utils/useBarcodeScanner';
|
||||
import ScannedBarcodes from './ScannedBarcodes';
|
||||
import { CameraRef } from './Camera/types';
|
||||
import Camera from './Camera';
|
||||
import { CameraResult } from './types';
|
||||
import Camera from './Camera/index';
|
||||
import { CameraResult, OnInsertBarcode } from './types';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import useBackHandler from '../../utils/hooks/useBackHandler';
|
||||
|
||||
const logger = Logger.create('CameraView');
|
||||
|
||||
@@ -28,8 +28,10 @@ interface Props {
|
||||
cameraType: CameraDirection;
|
||||
cameraRatio: string;
|
||||
onPhoto: (data: CameraResult)=> void;
|
||||
onCancel: ()=> void;
|
||||
onInsertBarcode: (barcodeText: string)=> void;
|
||||
// If null, cancelling should be handled by the parent
|
||||
// component
|
||||
onCancel: (()=> void)|null;
|
||||
onInsertBarcode: OnInsertBarcode;
|
||||
}
|
||||
|
||||
interface UseStyleProps {
|
||||
@@ -107,16 +109,7 @@ const CameraViewComponent: React.FC<Props> = props => {
|
||||
const cameraRef = useRef<CameraRef|null>(null);
|
||||
const [cameraReady, setCameraReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
props.onCancel();
|
||||
return true;
|
||||
};
|
||||
BackButtonService.addHandler(handler);
|
||||
return () => {
|
||||
BackButtonService.removeHandler(handler);
|
||||
};
|
||||
}, [props.onCancel]);
|
||||
useBackHandler(props.onCancel);
|
||||
|
||||
const onCameraReverse = useCallback(() => {
|
||||
const newDirection = props.cameraType === CameraDirection.Front ? CameraDirection.Back : CameraDirection.Front;
|
||||
@@ -166,7 +159,7 @@ const CameraViewComponent: React.FC<Props> = props => {
|
||||
overlay = <View style={styles.errorContainer}>
|
||||
<Text>{_('Missing camera permission')}</Text>
|
||||
<LinkButton onPress={() => Linking.openSettings()}>{_('Open settings')}</LinkButton>
|
||||
<PrimaryButton onPress={props.onCancel}>{_('Go back')}</PrimaryButton>
|
||||
{props.onCancel && <PrimaryButton onPress={props.onCancel}>{_('Go back')}</PrimaryButton>}
|
||||
</View>;
|
||||
} else {
|
||||
overlay = <>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from 'react';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import CameraViewMultiPage, { OnComplete } from './CameraViewMultiPage';
|
||||
import { CameraResult, OnInsertBarcode } from './types';
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../../utils/types';
|
||||
import TestProviderStack from '../testing/TestProviderStack';
|
||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
|
||||
import { startCamera, takePhoto } from './utils/testing';
|
||||
|
||||
interface WrapperProps {
|
||||
onCancel?: ()=> void;
|
||||
onInsertBarcode?: OnInsertBarcode;
|
||||
onComplete?: OnComplete;
|
||||
}
|
||||
|
||||
let store: Store<AppState>;
|
||||
const WrappedCamera: React.FC<WrapperProps> = ({
|
||||
onCancel = jest.fn(),
|
||||
onComplete = jest.fn(),
|
||||
onInsertBarcode = jest.fn(),
|
||||
}) => {
|
||||
return <TestProviderStack store={store}>
|
||||
<CameraViewMultiPage
|
||||
themeId={Setting.THEME_LIGHT}
|
||||
onCancel={onCancel}
|
||||
onComplete={onComplete}
|
||||
onInsertBarcode={onInsertBarcode}
|
||||
/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
const getNextButton = () => screen.getByRole('button', { name: 'Next' });
|
||||
const queryPhotoCount = () => screen.queryByTestId('photo-count');
|
||||
|
||||
describe('CameraViewMultiPage', () => {
|
||||
beforeEach(() => {
|
||||
store = createMockReduxStore();
|
||||
});
|
||||
|
||||
test('next button should be disabled until a photo has been taken', async () => {
|
||||
render(<WrappedCamera/>);
|
||||
expect(getNextButton()).toBeDisabled();
|
||||
startCamera();
|
||||
// Should still be disabled after starting the camera
|
||||
expect(getNextButton()).toBeDisabled();
|
||||
|
||||
await takePhoto();
|
||||
await waitFor(() => {
|
||||
expect(getNextButton()).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should show a count of the number of photos taken', async () => {
|
||||
render(<WrappedCamera/>);
|
||||
startCamera();
|
||||
|
||||
expect(queryPhotoCount()).toBeNull();
|
||||
|
||||
for (let i = 1; i < 3; i++) {
|
||||
await takePhoto();
|
||||
await waitFor(() => {
|
||||
expect(queryPhotoCount()).toHaveTextContent(String(i));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('pressing "Next" should call onComplete with photo URI(s)', async () => {
|
||||
const onComplete = jest.fn();
|
||||
render(<WrappedCamera onComplete={onComplete}/>);
|
||||
startCamera();
|
||||
|
||||
await takePhoto();
|
||||
await waitFor(() => {
|
||||
expect(getNextButton()).not.toBeDisabled();
|
||||
});
|
||||
|
||||
fireEvent.press(getNextButton());
|
||||
|
||||
const imageResults: CameraResult[] = onComplete.mock.lastCall[0];
|
||||
expect(imageResults).toHaveLength(1);
|
||||
expect(imageResults[0].uri).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import * as React from 'react';
|
||||
import { CameraResult } from './types';
|
||||
import { View, StyleSheet, Platform, ImageBackground, ViewStyle, TextStyle } from 'react-native';
|
||||
import CameraView from './CameraView';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { themeStyle } from '../global-style';
|
||||
import { Button, Text } from 'react-native-paper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import shim from '@joplin/lib/shim';
|
||||
|
||||
export type OnComplete = (photos: CameraResult[])=> void;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
onCancel: ()=> void;
|
||||
onComplete: OnComplete;
|
||||
onInsertBarcode: (barcodeText: string)=> void;
|
||||
}
|
||||
|
||||
const useStyle = (themeId: number) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return StyleSheet.create({
|
||||
camera: {
|
||||
flex: 1,
|
||||
},
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
},
|
||||
bottomRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
photoWrapper: {
|
||||
flexGrow: 1,
|
||||
minHeight: 82,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
imagePreview: {
|
||||
maxWidth: 70,
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
imageCountText: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
marginTop: 'auto',
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.backgroundColor2,
|
||||
color: theme.color2,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
interface PhotoProps {
|
||||
source: CameraResult;
|
||||
backgroundStyle: ViewStyle;
|
||||
textStyle: TextStyle;
|
||||
label: number;
|
||||
}
|
||||
|
||||
const PhotoPreview: React.FC<PhotoProps> = ({ source, label, backgroundStyle, textStyle }) => {
|
||||
const [uri, setUri] = useState('');
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
if (Platform.OS === 'web') {
|
||||
const file = await shim.fsDriver().fileAtPath(source.uri);
|
||||
if (event.cancelled) return;
|
||||
|
||||
const uri = URL.createObjectURL(file);
|
||||
setUri(uri);
|
||||
|
||||
event.onCleanup(() => {
|
||||
URL.revokeObjectURL(uri);
|
||||
});
|
||||
} else {
|
||||
setUri(source.uri);
|
||||
}
|
||||
}, [source]);
|
||||
return <ImageBackground
|
||||
style={backgroundStyle}
|
||||
resizeMode='contain'
|
||||
source={{ uri }}
|
||||
accessibilityLabel={_('%d photo(s) taken', label)}
|
||||
>
|
||||
<Text
|
||||
style={textStyle}
|
||||
testID='photo-count'
|
||||
>{label}</Text>
|
||||
</ImageBackground>;
|
||||
};
|
||||
|
||||
const CameraViewMultiPage: React.FC<Props> = ({
|
||||
onInsertBarcode, onCancel, onComplete, themeId,
|
||||
}) => {
|
||||
const [photos, setPhotos] = useState<CameraResult[]>([]);
|
||||
const onPhoto = useCallback((data: CameraResult) => {
|
||||
setPhotos(photos => [...photos, data]);
|
||||
}, []);
|
||||
|
||||
const onDonePressed = useCallback(() => {
|
||||
onComplete(photos);
|
||||
}, [photos, onComplete]);
|
||||
|
||||
const styles = useStyle(themeId);
|
||||
const renderLastPhoto = () => {
|
||||
if (!photos.length) return null;
|
||||
|
||||
return <PhotoPreview
|
||||
label={photos.length}
|
||||
source={photos[photos.length - 1]}
|
||||
backgroundStyle={styles.imagePreview}
|
||||
textStyle={styles.imageCountText}
|
||||
/>;
|
||||
};
|
||||
|
||||
return <View style={styles.root}>
|
||||
<CameraView
|
||||
onCancel={null}
|
||||
onInsertBarcode={onInsertBarcode}
|
||||
style={styles.camera}
|
||||
onPhoto={onPhoto}
|
||||
/>
|
||||
<View style={styles.bottomRow}>
|
||||
<Button icon='arrow-left' onPress={onCancel}>{_('Back')}</Button>
|
||||
<View style={styles.photoWrapper}>
|
||||
{renderLastPhoto()}
|
||||
</View>
|
||||
<Button
|
||||
icon='arrow-right'
|
||||
disabled={photos.length === 0}
|
||||
onPress={onDonePressed}
|
||||
>{_('Next')}</Button>
|
||||
</View>
|
||||
</View>;
|
||||
};
|
||||
|
||||
export default CameraViewMultiPage;
|
||||
@@ -8,11 +8,12 @@ import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
|
||||
import { Chip, Text } from 'react-native-paper';
|
||||
import { isCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { OnInsertBarcode } from './types';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
codeScanner: BarcodeScanner;
|
||||
onInsertCode: (codeText: string)=> void;
|
||||
onInsertCode: OnInsertBarcode;
|
||||
}
|
||||
|
||||
const useStyles = () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
export type OnInsertBarcode = (barcodeText: string)=> void;
|
||||
|
||||
export interface CameraResult {
|
||||
uri: string;
|
||||
type: string;
|
||||
|
||||
28
packages/app-mobile/components/CameraView/utils/testing.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Utilities for use with the CameraView.jest.tsx mock
|
||||
|
||||
import { fireEvent, screen } from '@testing-library/react-native';
|
||||
|
||||
export const rejectCameraPermission = () => {
|
||||
const rejectPermissionButton = screen.getByRole('button', { name: 'Reject permission' });
|
||||
fireEvent.press(rejectPermissionButton);
|
||||
};
|
||||
|
||||
export const acceptCameraPermission = () => {
|
||||
const acceptPermissionButton = screen.getByRole('button', { name: 'Accept permission' });
|
||||
fireEvent.press(acceptPermissionButton);
|
||||
};
|
||||
|
||||
export const startCamera = () => {
|
||||
const startCameraButton = screen.getByRole('button', { name: 'On camera ready' });
|
||||
fireEvent.press(startCameraButton);
|
||||
};
|
||||
|
||||
export const takePhoto = async () => {
|
||||
const takePhotoButton = await screen.findByRole('button', { name: 'Take photo' });
|
||||
fireEvent.press(takePhotoButton);
|
||||
};
|
||||
|
||||
export const setQrCodeData = (data: string) => {
|
||||
const qrCodeDataInput = screen.getByPlaceholderText('QR code data');
|
||||
fireEvent.changeText(qrCodeDataInput, data);
|
||||
};
|
||||
@@ -24,6 +24,9 @@ const builtInCommandNames = [
|
||||
EditorCommandType.IndentMore,
|
||||
`editor.${EditorCommandType.SwapLineDown}`,
|
||||
`editor.${EditorCommandType.SwapLineUp}`,
|
||||
`editor.${EditorCommandType.DeleteLine}`,
|
||||
`editor.${EditorCommandType.DuplicateLine}`,
|
||||
`editor.${EditorCommandType.SortSelectedLines}`,
|
||||
'-',
|
||||
'insertDateTime',
|
||||
'-',
|
||||
|
||||
@@ -10,6 +10,9 @@ const omitFromDefault: string[] = [
|
||||
'editor.textHeading5',
|
||||
`editor.${EditorCommandType.SwapLineDown}`,
|
||||
`editor.${EditorCommandType.SwapLineUp}`,
|
||||
`editor.${EditorCommandType.DeleteLine}`,
|
||||
`editor.${EditorCommandType.DuplicateLine}`,
|
||||
`editor.${EditorCommandType.SortSelectedLines}`,
|
||||
];
|
||||
|
||||
// The "hide keyboard" button is only needed on iOS, so only show it there by default.
|
||||
|
||||
@@ -60,6 +60,7 @@ const useCss = (editorTheme: Theme) => {
|
||||
body, html {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hide the scrollbar. See scrollbar accessibility concerns
|
||||
|
||||
@@ -107,6 +107,21 @@ const declarations: CommandDeclaration[] = [
|
||||
label: () => _('Swap line up'),
|
||||
iconName: 'material chevron-double-up',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.DeleteLine}`,
|
||||
label: () => _('Delete line'),
|
||||
iconName: 'material close',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.DuplicateLine}`,
|
||||
label: () => _('Duplicate line'),
|
||||
iconName: 'material content-duplicate',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.SortSelectedLines}`,
|
||||
label: () => _('Sort selected lines'),
|
||||
iconName: 'material sort-alphabetical-ascending',
|
||||
},
|
||||
{
|
||||
name: EditorCommandType.ToggleSearch,
|
||||
label: () => _('Search'),
|
||||
|
||||
@@ -34,7 +34,6 @@ import { themeStyle, editorFont } from '../../global-style';
|
||||
import shared, { BaseNoteScreenComponent, Props as BaseProps } from '@joplin/lib/components/shared/note-screen-shared';
|
||||
import SelectDateTimeDialog from '../../SelectDateTimeDialog';
|
||||
import ShareExtension from '../../../utils/ShareExtension.js';
|
||||
import CameraView from '../../CameraView/CameraView';
|
||||
import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import ImageEditor from '../../NoteEditor/ImageEditor/ImageEditor';
|
||||
@@ -68,6 +67,7 @@ import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getAct
|
||||
import EditorPluginHandler from '@joplin/lib/services/plugins/EditorPluginHandler';
|
||||
import AudioRecordingBanner from '../../voiceTyping/AudioRecordingBanner';
|
||||
import SpeechToTextBanner from '../../voiceTyping/SpeechToTextBanner';
|
||||
import CameraView from '../../CameraView/CameraView';
|
||||
import ShareNoteDialog from '../ShareNoteDialog';
|
||||
import stateToWhenClauseContext from '../../../services/commands/stateToWhenClauseContext';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
@@ -837,6 +837,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
pickerResponse: PickerResponse,
|
||||
fileType: string,
|
||||
): Promise<ResourceEntity|null> {
|
||||
logger.debug('Attaching file:', pickerResponse?.uri);
|
||||
if (!pickerResponse) {
|
||||
// User has cancelled
|
||||
return null;
|
||||
@@ -918,11 +919,17 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
return resource;
|
||||
}
|
||||
|
||||
private cameraView_onPhoto(data: CameraResult) {
|
||||
void this.attachFile(
|
||||
data,
|
||||
'image',
|
||||
);
|
||||
private async cameraView_onPhoto(data: CameraResult|CameraResult[]) {
|
||||
if (!Array.isArray(data)) {
|
||||
data = [data];
|
||||
}
|
||||
|
||||
for (const item of data) {
|
||||
await this.attachFile(
|
||||
item,
|
||||
'image',
|
||||
);
|
||||
}
|
||||
|
||||
this.setState({ showCamera: false });
|
||||
}
|
||||
@@ -1524,10 +1531,10 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
|
||||
if (this.state.showCamera) {
|
||||
return <CameraView
|
||||
style={{ flex: 1 }}
|
||||
onPhoto={this.cameraView_onPhoto}
|
||||
onInsertBarcode={this.cameraView_onInsertBarcode}
|
||||
onCancel={this.cameraView_onCancel}
|
||||
style={{ flex: 1 }}
|
||||
/>;
|
||||
} else if (this.state.showImageEditor) {
|
||||
return <ImageEditor
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { View, StyleSheet, SafeAreaView, ScrollView } from 'react-native';
|
||||
import { AppState } from '../../utils/types';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import Revision from '@joplin/lib/models/Revision';
|
||||
@@ -102,6 +102,30 @@ const useStyles = (themeId: number) => {
|
||||
root: {
|
||||
...theme.rootStyle,
|
||||
},
|
||||
titleContainer: {
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
borderTopColor: theme.dividerColor,
|
||||
borderTopWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
titleViewContainer: {
|
||||
flex: 0,
|
||||
flexDirection: 'row',
|
||||
flexBasis: 'auto',
|
||||
},
|
||||
titleText: {
|
||||
flex: 1,
|
||||
marginTop: 0,
|
||||
paddingLeft: 0,
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontWeight: 'bold',
|
||||
fontSize: theme.fontSize,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
@@ -188,6 +212,16 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
||||
>{restoreButtonTitle}</PrimaryButton>
|
||||
);
|
||||
|
||||
const titleComponent = (
|
||||
<SafeAreaView style={styles.titleContainer}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.titleViewContainer}>
|
||||
<Text style={styles.titleText}>{note?.title ?? ''}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
return <View style={styles.root}>
|
||||
<ScreenHeader menuOptions={menuOptions} title={_('Note history')} />
|
||||
<View style={styles.controls}>
|
||||
@@ -212,6 +246,7 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
||||
onPress={onHelpPress}
|
||||
/>
|
||||
</View>
|
||||
{note ? titleComponent : ''}
|
||||
<NoteBodyViewer
|
||||
style={styles.noteViewer}
|
||||
noteBody={note?.body ?? _('No revision selected')}
|
||||
|
||||
@@ -48,7 +48,7 @@ const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypi
|
||||
|
||||
const [redownloadCounter, setRedownloadCounter] = useState(0);
|
||||
|
||||
useQueuedAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||
useQueuedAsyncEffect(async (event) => {
|
||||
try {
|
||||
// Reset the error: If starting voice typing again resolves the error, the error
|
||||
// should be hidden (and voice typing should start).
|
||||
|
||||
@@ -533,7 +533,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
@@ -568,7 +568,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
@@ -767,7 +767,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -810,7 +810,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 141;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
|
||||
@@ -51,16 +51,16 @@
|
||||
"punycode": "2.3.1",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.2",
|
||||
"react-native-device-info": "10.14.0",
|
||||
"react-native-device-info": "14.0.4",
|
||||
"react-native-dropdownalert": "5.1.0",
|
||||
"react-native-exit-app": "2.0.0",
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fingerprint-scanner": "6.0.0",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-image-picker": "7.1.1",
|
||||
"react-native-localize": "3.2.1",
|
||||
"react-native-modal-datetime-picker": "17.1.0",
|
||||
"react-native-image-picker": "7.2.3",
|
||||
"react-native-localize": "3.4.1",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-paper": "5.13.4",
|
||||
"react-native-popup-menu": "0.17.0",
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
@@ -68,14 +68,14 @@
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "10.2.1",
|
||||
"react-native-share": "12.0.9",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-vector-icons": "10.1.0",
|
||||
"react-native-vector-icons": "10.2.0",
|
||||
"react-native-version-info": "1.1.1",
|
||||
"react-native-vosk": "0.1.12",
|
||||
"react-native-webview": "13.13.5",
|
||||
"react-native-zip-archive": "6.1.2",
|
||||
"react-native-zip-archive": "7.0.1",
|
||||
"react-redux": "8.1.3",
|
||||
"redux": "4.2.1",
|
||||
"rn-fetch-blob": "0.12.0",
|
||||
@@ -94,9 +94,9 @@
|
||||
"@joplin/tools": "~3.4",
|
||||
"@js-draw/material-icons": "1.30.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@react-native-community/cli": "15.0.1",
|
||||
"@react-native-community/cli-platform-android": "15.1.3",
|
||||
"@react-native-community/cli-platform-ios": "15.0.1",
|
||||
"@react-native-community/cli": "16.0.2",
|
||||
"@react-native-community/cli-platform-android": "16.0.2",
|
||||
"@react-native-community/cli-platform-ios": "16.0.2",
|
||||
"@react-native/babel-preset": "0.79.2",
|
||||
"@react-native/metro-config": "0.79.2",
|
||||
"@react-native/typescript-config": "0.79.2",
|
||||
@@ -104,10 +104,10 @@
|
||||
"@testing-library/react-native": "13.2.0",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "18.19.67",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/node": "18.19.86",
|
||||
"@types/react": "19.0.14",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.126",
|
||||
"@types/serviceworker": "0.0.127",
|
||||
"@types/tar-stream": "3.1.3",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
@@ -120,7 +120,7 @@
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jetifier": "2.0.0",
|
||||
"js-draw": "1.30.0",
|
||||
"jsdom": "24.1.3",
|
||||
"jsdom": "25.0.1",
|
||||
"nodemon": "3.1.9",
|
||||
"punycode": "2.3.1",
|
||||
"react-dom": "19.0.0",
|
||||
@@ -137,7 +137,7 @@
|
||||
"url-loader": "4.1.1",
|
||||
"webpack": "5.97.1",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.0.4"
|
||||
"webpack-dev-server": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
hash:"e857ce4f63c45b5c1d25eb9a76c2127d", files: {
|
||||
hash:"39ce682c4ff5dd85d571d0e99718648f", files: {
|
||||
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
|
||||
|
||||
@@ -1 +1 @@
|
||||
module.exports = {"hash":"e857ce4f63c45b5c1d25eb9a76c2127d","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}
|
||||
module.exports = {"hash":"39ce682c4ff5dd85d571d0e99718648f","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}
|
||||
@@ -1353,7 +1353,6 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
isOpen={this.props.showSideMenu}
|
||||
disableGestures={disableSideMenuGestures}
|
||||
>
|
||||
<StatusBar barStyle={statusBarStyle} />
|
||||
<View style={{ flexGrow: 1, flexShrink: 1, flexBasis: '100%' }}>
|
||||
<SafeAreaView style={{ flex: 0, backgroundColor: theme.backgroundColor2 }}/>
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
@@ -1362,11 +1361,6 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
</View>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied */}
|
||||
<DropdownAlert alert={(func: any) => (this.dropdownAlert_ = func)} />
|
||||
{ !shouldShowMainContent && <BiometricPopup
|
||||
dispatch={this.props.dispatch}
|
||||
themeId={this.props.themeId}
|
||||
sensorInfo={this.state.sensorInfo}
|
||||
/> }
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</SideMenu>
|
||||
@@ -1416,12 +1410,21 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
},
|
||||
}}>
|
||||
<DialogManager themeId={this.props.themeId}>
|
||||
<StatusBar barStyle={statusBarStyle} />
|
||||
<MenuProvider
|
||||
style={{ flex: 1 }}
|
||||
closeButtonLabel={_('Dismiss')}
|
||||
>
|
||||
<FocusControl.MainAppContent style={{ flex: 1 }}>
|
||||
{mainContent}
|
||||
{shouldShowMainContent ? mainContent : (
|
||||
<SafeAreaView>
|
||||
<BiometricPopup
|
||||
dispatch={this.props.dispatch}
|
||||
themeId={this.props.themeId}
|
||||
sensorInfo={this.state.sensorInfo}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)}
|
||||
</FocusControl.MainAppContent>
|
||||
</MenuProvider>
|
||||
</DialogManager>
|
||||
|
||||
@@ -2,28 +2,62 @@ import { setupDatabase } from '@joplin/lib/testing/test-utils';
|
||||
import whisper from './whisper';
|
||||
import { dirname, join } from 'path';
|
||||
import { exists, mkdir, remove, writeFile } from 'fs-extra';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { NativeModules } from 'react-native';
|
||||
const SpeechToTextModule = NativeModules.SpeechToTextModule;
|
||||
|
||||
jest.mock('react-native', () => {
|
||||
const reactNative = jest.requireActual('react-native');
|
||||
|
||||
let lastPrompt: string|null = null;
|
||||
|
||||
// Set properties on reactNative rather than creating a new object with
|
||||
// {...reactNative, ...}. Creating a new object triggers deprecation warnings.
|
||||
// See https://github.com/facebook/react-native/issues/28839.
|
||||
reactNative.NativeModules.SpeechToTextModule = {
|
||||
convertNext: () => 'Test. This is test output. Test!',
|
||||
runTests: ()=> {},
|
||||
openSession: jest.fn(() => {
|
||||
openSession: jest.fn((_path, _locale, prompt) => {
|
||||
lastPrompt = prompt;
|
||||
|
||||
const someId = 1234;
|
||||
return someId;
|
||||
}),
|
||||
closeSession: jest.fn(),
|
||||
startRecording: jest.fn(),
|
||||
convertAvailable: jest.fn(() => ''),
|
||||
testing__lastPrompt: () => {
|
||||
return lastPrompt;
|
||||
},
|
||||
};
|
||||
|
||||
return reactNative;
|
||||
});
|
||||
|
||||
interface ModelConfig {
|
||||
output: {
|
||||
stringReplacements: string[][];
|
||||
regexReplacements: string[][];
|
||||
};
|
||||
}
|
||||
|
||||
const defaultModelConfig: ModelConfig = {
|
||||
output: { stringReplacements: [], regexReplacements: [] },
|
||||
};
|
||||
|
||||
const createMockModel = async (config: ModelConfig = defaultModelConfig) => {
|
||||
const whisperBaseDirectory = dirname(whisper.modelLocalFilepath('en'));
|
||||
await mkdir(whisperBaseDirectory);
|
||||
|
||||
const modelDirectory = join(whisperBaseDirectory, 'model');
|
||||
await mkdir(modelDirectory);
|
||||
|
||||
await writeFile(join(modelDirectory, 'model.bin'), 'mock model', 'utf-8');
|
||||
await writeFile(join(modelDirectory, 'config.json'), JSON.stringify(config), 'utf-8');
|
||||
|
||||
return modelDirectory;
|
||||
};
|
||||
|
||||
describe('whisper', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabase(0);
|
||||
@@ -45,14 +79,7 @@ describe('whisper', () => {
|
||||
});
|
||||
|
||||
test('should apply post-processing replacements specified in the model config', async () => {
|
||||
const whisperBaseDirectory = dirname(whisper.modelLocalFilepath('en'));
|
||||
await mkdir(whisperBaseDirectory);
|
||||
|
||||
const modelDirectory = join(whisperBaseDirectory, 'model');
|
||||
await mkdir(modelDirectory);
|
||||
|
||||
await writeFile(join(modelDirectory, 'model.bin'), 'mock model', 'utf-8');
|
||||
await writeFile(join(modelDirectory, 'config.json'), JSON.stringify({
|
||||
const modelDirectory = await createMockModel({
|
||||
output: {
|
||||
stringReplacements: [
|
||||
['Test', 'replaced'],
|
||||
@@ -61,7 +88,7 @@ describe('whisper', () => {
|
||||
['replace[d]', 'replaced again!'],
|
||||
],
|
||||
},
|
||||
}), 'utf-8');
|
||||
});
|
||||
|
||||
let lastFinalizedText = '';
|
||||
const onFinalize = jest.fn((text: string) => {
|
||||
@@ -85,4 +112,29 @@ describe('whisper', () => {
|
||||
lastFinalizedText,
|
||||
).toBe('\n\nreplaced again!. This is test output. replaced again!!');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ glossary: '', expectedPrompt: '' },
|
||||
{ glossary: 'test', expectedPrompt: 'Glossary: test' },
|
||||
{ glossary: 'Joplin, app', expectedPrompt: 'Glossary: Joplin, app' },
|
||||
// Should not include the "Glossary:" prefix if there's no translation for it
|
||||
{ glossary: 'Joplin, app', expectedPrompt: 'Joplin, app', locale: 'testLocale-test' },
|
||||
])('should construct a prompt from the user-specified glossary (%j)', async ({ glossary, expectedPrompt, locale }) => {
|
||||
Setting.setValue('voiceTyping.glossary', glossary);
|
||||
|
||||
const modelDirectory = await createMockModel();
|
||||
const session = await whisper.build({
|
||||
modelPath: modelDirectory,
|
||||
callbacks: {
|
||||
onFinalize: () => {
|
||||
return session.stop();
|
||||
},
|
||||
onPreview: jest.fn(),
|
||||
},
|
||||
locale: locale ?? 'en',
|
||||
});
|
||||
await session.start();
|
||||
|
||||
expect(SpeechToTextModule.testing__lastPrompt()).toBe(expectedPrompt);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { rtrimSlashes } from '@joplin/utils/path';
|
||||
import { dirname, join } from 'path';
|
||||
import { NativeModules } from 'react-native';
|
||||
import { SpeechToTextCallbacks, VoiceTypingProvider, VoiceTypingSession } from './VoiceTyping';
|
||||
import { languageCodeOnly } from '@joplin/lib/locale';
|
||||
import { languageCodeOnly, stringByLocale } from '@joplin/lib/locale';
|
||||
|
||||
const logger = Logger.create('voiceTyping/whisper');
|
||||
|
||||
@@ -178,8 +178,30 @@ class Whisper implements VoiceTypingSession {
|
||||
}
|
||||
}
|
||||
|
||||
const getGlossaryPrompt = (locale: string) => {
|
||||
const glossary = Setting.value('voiceTyping.glossary');
|
||||
if (!glossary) return '';
|
||||
|
||||
// Re-define the "_" localization function so that it uses the transcription locale (as opposed to the UI locale).
|
||||
const _ = (text: string) => {
|
||||
return stringByLocale(locale, text);
|
||||
};
|
||||
let glossaryPrefix = _('Glossary:');
|
||||
|
||||
// Prefer no prefix if no appropriate translation of "Glossary:" is available:
|
||||
if (glossaryPrefix === 'Glossary:' && languageCodeOnly(locale) !== 'en') {
|
||||
glossaryPrefix = '';
|
||||
}
|
||||
|
||||
return `${glossaryPrefix} ${glossary}`.trim();
|
||||
};
|
||||
|
||||
const getPrompt = (locale: string, localeToPrompt: Map<string, string>) => {
|
||||
return localeToPrompt.get(languageCodeOnly(locale)) ?? '';
|
||||
const basePrompt = localeToPrompt.get(languageCodeOnly(locale));
|
||||
return [
|
||||
basePrompt,
|
||||
getGlossaryPrompt(locale),
|
||||
].filter(part => !!part).join(' ');
|
||||
};
|
||||
|
||||
const modelLocalDirectory = () => {
|
||||
|
||||
20
packages/app-mobile/utils/hooks/useBackHandler.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
import BackButtonService from '../../services/BackButtonService';
|
||||
|
||||
type OnBackPress = ()=>(void|boolean);
|
||||
|
||||
const useBackHandler = (onBackPress: OnBackPress|null) => {
|
||||
useEffect(() => {
|
||||
if (!onBackPress) return () => {};
|
||||
|
||||
const handler = () => {
|
||||
return !!(onBackPress() ?? true);
|
||||
};
|
||||
BackButtonService.addHandler(handler);
|
||||
return () => {
|
||||
BackButtonService.removeHandler(handler);
|
||||
};
|
||||
}, [onBackPress]);
|
||||
};
|
||||
|
||||
export default useBackHandler;
|
||||
@@ -8,9 +8,9 @@ import { chdir, cwd } from 'process';
|
||||
import { execCommand } from '@joplin/utils';
|
||||
import { glob } from 'glob';
|
||||
import readRepositoryJson from './utils/readRepositoryJson';
|
||||
import waitForCliInput from './utils/waitForCliInput';
|
||||
import getPathToPatchFileFor from './utils/getPathToPatchFileFor';
|
||||
import getCurrentCommitHash from './utils/getCurrentCommitHash';
|
||||
import { waitForCliInput } from '@joplin/utils/cli';
|
||||
|
||||
interface Options {
|
||||
beforeInstall: (buildDir: string, pluginName: string)=> Promise<void>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execCommand } from '@joplin/utils';
|
||||
import waitForCliInput from '../utils/waitForCliInput';
|
||||
import { copy } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { waitForCliInput } from '@joplin/utils/cli';
|
||||
import buildDefaultPlugins from '../buildDefaultPlugins';
|
||||
import getPathToPatchFileFor from '../utils/getPathToPatchFileFor';
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@joplin/lib": "~3.4",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react": "18.3.20",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"jest": "29.7.0",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "18.19.67",
|
||||
"@types/node": "18.19.86",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"coveralls": "3.1.1",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "18.19.67",
|
||||
"@types/node": "18.19.86",
|
||||
"jest": "29.7.0",
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
|
||||
@@ -98,7 +98,7 @@ export default class ClipperServer {
|
||||
});
|
||||
}
|
||||
|
||||
public async findAvailablePort() {
|
||||
public async findAvailablePort(): Promise<number> {
|
||||
const tcpPortUsed = require('tcp-port-used');
|
||||
|
||||
let state = null;
|
||||
|
||||
@@ -448,7 +448,8 @@ export default class Synchronizer {
|
||||
// Before synchronising make sure all share_id properties are set
|
||||
// correctly so as to share/unshare the right items.
|
||||
try {
|
||||
await Folder.updateAllShareIds(this.resourceService());
|
||||
if (this.shareService_) await this.shareService_.maintenance();
|
||||
await Folder.updateAllShareIds(this.resourceService(), this.shareService_ ? this.shareService_.shares : []);
|
||||
if (this.shareService_) await this.shareService_.checkShareConsistency();
|
||||
} catch (error) {
|
||||
if (error && error.code === ErrorCode.IsReadOnly) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as deleteNote from './deleteNote';
|
||||
import * as historyBackward from './historyBackward';
|
||||
import * as historyForward from './historyForward';
|
||||
import * as leaveSharedFolder from './leaveSharedFolder';
|
||||
import * as openMasterPasswordDialog from './openMasterPasswordDialog';
|
||||
import * as permanentlyDeleteNote from './permanentlyDeleteNote';
|
||||
import * as renderMarkup from './renderMarkup';
|
||||
@@ -14,6 +15,7 @@ const index: any[] = [
|
||||
deleteNote,
|
||||
historyBackward,
|
||||
historyForward,
|
||||
leaveSharedFolder,
|
||||
openMasterPasswordDialog,
|
||||
permanentlyDeleteNote,
|
||||
renderMarkup,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '../services/CommandService';
|
||||
import { _ } from '../locale';
|
||||
import ShareService from '../services/share/ShareService';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import shim from '../shim';
|
||||
|
||||
const logger = Logger.create('leaveSharedFolder');
|
||||
|
||||
@@ -11,10 +11,16 @@ export const declaration: CommandDeclaration = {
|
||||
label: () => _('Leave notebook...'),
|
||||
};
|
||||
|
||||
interface Options {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext, folderId: string = null) => {
|
||||
const answer = await shim.showConfirmationDialog(_('This will remove the notebook from your collection and you will no longer have access to its content. Do you wish to continue?'));
|
||||
execute: async (_context: CommandContext, folderId: string = null, { force = false }: Options = {}) => {
|
||||
const answer = force ? true : await shim.showConfirmationDialog(
|
||||
_('This will remove the notebook from your collection and you will no longer have access to its content. Do you wish to continue?'),
|
||||
);
|
||||
if (!answer) return;
|
||||
|
||||
try {
|
||||
@@ -1,8 +1,11 @@
|
||||
import shim from '../shim';
|
||||
const { useEffect } = shim.react();
|
||||
|
||||
type CleanupCallback = ()=> void;
|
||||
|
||||
export interface AsyncEffectEvent {
|
||||
cancelled: boolean;
|
||||
onCleanup: (callback: CleanupCallback)=> void;
|
||||
}
|
||||
|
||||
export type EffectFunction = (event: AsyncEffectEvent)=> Promise<void>;
|
||||
@@ -10,10 +13,24 @@ export type EffectFunction = (event: AsyncEffectEvent)=> Promise<void>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export default function(effect: EffectFunction, dependencies: any[]) {
|
||||
useEffect(() => {
|
||||
const event: AsyncEffectEvent = { cancelled: false };
|
||||
const onCleanupCallbacks: CleanupCallback[] = [];
|
||||
const event: AsyncEffectEvent = {
|
||||
cancelled: false,
|
||||
onCleanup: (callback) => {
|
||||
if (event.cancelled) {
|
||||
callback();
|
||||
} else {
|
||||
onCleanupCallbacks.push(callback);
|
||||
}
|
||||
},
|
||||
};
|
||||
void effect(event);
|
||||
return () => {
|
||||
event.cancelled = true;
|
||||
|
||||
for (const callback of onCleanupCallbacks) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, dependencies);
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
|
||||
const testPathIgnorePatterns = [
|
||||
'<rootDir>/node_modules/',
|
||||
'<rootDir>/rnInjectedJs/',
|
||||
'<rootDir>/vendor/',
|
||||
];
|
||||
|
||||
if (!process.env.IS_CONTINUOUS_INTEGRATION) {
|
||||
// We don't require all developers to have Rust to run the project, so we skip this test if not running in CI
|
||||
testPathIgnorePatterns.push('<rootDir>/services/interop/InteropService_Importer_OneNote.*');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
testMatch: [
|
||||
'**/*.test.js',
|
||||
],
|
||||
|
||||
testPathIgnorePatterns: [
|
||||
'<rootDir>/node_modules/',
|
||||
'<rootDir>/rnInjectedJs/',
|
||||
'<rootDir>/vendor/',
|
||||
],
|
||||
testPathIgnorePatterns: testPathIgnorePatterns,
|
||||
|
||||
testEnvironment: 'node',
|
||||
|
||||
|
||||
@@ -733,4 +733,4 @@ const stringByLocale = (locale: string, s: string, ...args: any[]): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export { _, _n, supportedLocales, languageName, currentLocale, localesFromLanguageCode, languageCodeOnly, countryDisplayName, localeStrings, setLocale, supportedLocalesToLanguages, defaultLocale, closestSupportedLocale, languageCode, countryCodeOnly };
|
||||
export { _, _n, supportedLocales, languageName, currentLocale, localesFromLanguageCode, languageCodeOnly, countryDisplayName, localeStrings, setLocale, supportedLocalesToLanguages, defaultLocale, closestSupportedLocale, stringByLocale, languageCode, countryCodeOnly };
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum MarkdownTableJustify {
|
||||
export interface MarkdownTableHeader {
|
||||
name: string;
|
||||
label: string;
|
||||
labelUrl?: string;
|
||||
filter?: (content: string)=> string;
|
||||
disableEscape?: boolean;
|
||||
disableHtmlEscape?: boolean;
|
||||
@@ -159,7 +160,11 @@ const markdownUtils = {
|
||||
const lineMd = [];
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const h = headers[i];
|
||||
headersMd.push(stringPadding(h.label, minCellWidth, ' ', stringPadding.RIGHT));
|
||||
let label = h.label;
|
||||
if (h.labelUrl) {
|
||||
label = `[${h.label}](${h.labelUrl})`;
|
||||
}
|
||||
headersMd.push(stringPadding(label, minCellWidth, ' ', stringPadding.RIGHT));
|
||||
|
||||
const justify = h.justify ? h.justify : MarkdownTableJustify.Left;
|
||||
|
||||
|
||||
@@ -6,9 +6,29 @@ import shim from '../shim';
|
||||
import Resource from '../models/Resource';
|
||||
import { FolderEntity, NoteEntity, ResourceEntity } from '../services/database/types';
|
||||
import ResourceService from '../services/ResourceService';
|
||||
import { StateShare } from '../services/share/reducer';
|
||||
|
||||
const testImagePath = `${supportDir}/photo.jpg`;
|
||||
|
||||
const makeStateShares = (folderIds: string[] | string, shareIds: string|string[] = 'abcd1234'): StateShare[] => {
|
||||
folderIds = (typeof folderIds === 'string') ? [folderIds] : folderIds;
|
||||
shareIds = (typeof shareIds === 'string') ? [shareIds] : shareIds;
|
||||
|
||||
const output: StateShare[] = [];
|
||||
|
||||
for (let i = 0; i < folderIds.length; i++) {
|
||||
output.push({
|
||||
folder_id: folderIds[i],
|
||||
master_key_id: '',
|
||||
id: shareIds[i],
|
||||
note_id: '',
|
||||
type: 3,
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
describe('models/Folder.sharing', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -40,7 +60,7 @@ describe('models/Folder.sharing', () => {
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder.id, share_id: 'abcd1234' });
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder.id));
|
||||
|
||||
const allItems = await allNotesFolders();
|
||||
for (const item of allItems) {
|
||||
@@ -86,7 +106,7 @@ describe('models/Folder.sharing', () => {
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
|
||||
|
||||
folder1 = await Folder.loadByTitle('folder 1');
|
||||
const folder2 = await Folder.loadByTitle('folder 2');
|
||||
@@ -120,7 +140,7 @@ describe('models/Folder.sharing', () => {
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
|
||||
|
||||
folder1 = await Folder.loadByTitle('folder 1');
|
||||
let folder2 = await Folder.loadByTitle('folder 2');
|
||||
@@ -132,7 +152,7 @@ describe('models/Folder.sharing', () => {
|
||||
// Move the folder outside the shared folder
|
||||
|
||||
await Folder.save({ id: folder2.id, parent_id: folder3.id });
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
|
||||
folder2 = await Folder.loadByTitle('folder 2');
|
||||
expect(folder2.share_id).toBe('');
|
||||
|
||||
@@ -140,12 +160,45 @@ describe('models/Folder.sharing', () => {
|
||||
|
||||
{
|
||||
await Folder.save({ id: folder2.id, parent_id: folder1.id });
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
|
||||
folder2 = await Folder.loadByTitle('folder 2');
|
||||
expect(folder2.share_id).toBe('abcd1234');
|
||||
}
|
||||
}));
|
||||
|
||||
it('should unshare a subfolder of a shared folder when it is moved to the root', (async () => {
|
||||
let folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'folder 2',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
|
||||
const stateShares: StateShare[] = makeStateShares(folder1.id);
|
||||
|
||||
await Folder.updateAllShareIds(resourceService(), stateShares);
|
||||
|
||||
folder1 = await Folder.loadByTitle('folder 1');
|
||||
let folder2 = await Folder.loadByTitle('folder 2');
|
||||
|
||||
expect(folder1.share_id).toBe('abcd1234');
|
||||
expect(folder2.share_id).toBe('abcd1234');
|
||||
|
||||
// Move the subfolder to the root
|
||||
|
||||
await Folder.save({ id: folder2.id, parent_id: '' });
|
||||
await Folder.updateAllShareIds(resourceService(), stateShares);
|
||||
folder2 = await Folder.loadByTitle('folder 2');
|
||||
expect(folder2.share_id).toBe('');
|
||||
}));
|
||||
|
||||
it('should apply the share ID to all notes', (async () => {
|
||||
const folder1 = await createFolderTree('', [
|
||||
{
|
||||
@@ -179,7 +232,7 @@ describe('models/Folder.sharing', () => {
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
|
||||
|
||||
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
const note2: NoteEntity = await Note.loadByTitle('note 2');
|
||||
@@ -209,7 +262,7 @@ describe('models/Folder.sharing', () => {
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
|
||||
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
|
||||
expect(note1.share_id).toBe('abcd1234');
|
||||
@@ -217,7 +270,7 @@ describe('models/Folder.sharing', () => {
|
||||
// Move the note outside of the shared folder
|
||||
|
||||
await Note.save({ id: note1.id, parent_id: folder2.id });
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
|
||||
|
||||
{
|
||||
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
@@ -227,7 +280,7 @@ describe('models/Folder.sharing', () => {
|
||||
// Move the note back inside the shared folder
|
||||
|
||||
await Note.save({ id: note1.id, parent_id: folder1.id });
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
|
||||
|
||||
{
|
||||
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
@@ -255,7 +308,7 @@ describe('models/Folder.sharing', () => {
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
|
||||
|
||||
let note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
let note2: NoteEntity = await Note.loadByTitle('note 2');
|
||||
@@ -265,7 +318,7 @@ describe('models/Folder.sharing', () => {
|
||||
expect(note2.share_id).toBe('abcd1234');
|
||||
|
||||
await Note.save({ id: note1.id, parent_id: folder2.id });
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
|
||||
|
||||
note1 = await Note.loadByTitle('note 1');
|
||||
note2 = await Note.loadByTitle('note 2');
|
||||
@@ -273,6 +326,60 @@ describe('models/Folder.sharing', () => {
|
||||
expect(note2.share_id).toBe('abcd1234');
|
||||
}));
|
||||
|
||||
it('should clear the share ID if that share no longer exists', (async () => {
|
||||
const folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
},
|
||||
{
|
||||
title: 'note 2',
|
||||
},
|
||||
{
|
||||
title: 'subfolder',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder 2',
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
|
||||
let note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
await shim.attachFileToNote(note1, testImagePath);
|
||||
await resourceService().indexNoteResources();
|
||||
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd1234' });
|
||||
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder1.id));
|
||||
|
||||
note1 = await Note.loadByTitle('note 1');
|
||||
let note2: NoteEntity = await Note.loadByTitle('note 2');
|
||||
let resource: ResourceEntity = (await Resource.all())[0];
|
||||
let subFolder: FolderEntity = await Folder.loadByTitle('subfolder');
|
||||
|
||||
expect(note1.share_id).toBe('abcd1234');
|
||||
expect(note2.share_id).toBe('abcd1234');
|
||||
expect(resource.share_id).toBe('abcd1234');
|
||||
expect(subFolder.share_id).toBe('abcd1234');
|
||||
|
||||
await Folder.updateAllShareIds(resourceService(), []);
|
||||
|
||||
note1 = await Note.loadByTitle('note 1');
|
||||
note2 = await Note.loadByTitle('note 2');
|
||||
resource = (await Resource.all())[0];
|
||||
subFolder = await Folder.loadByTitle('subfolder');
|
||||
|
||||
expect(note1.share_id).toBe('');
|
||||
expect(note2.share_id).toBe('');
|
||||
expect(resource.share_id).toBe('');
|
||||
expect(subFolder.share_id).toBe('');
|
||||
}));
|
||||
|
||||
it('should apply the note share ID to its resources', async () => {
|
||||
const resourceService = new ResourceService();
|
||||
|
||||
@@ -295,7 +402,7 @@ describe('models/Folder.sharing', () => {
|
||||
]);
|
||||
|
||||
await Folder.save({ id: folder.id, share_id: 'abcd1234' });
|
||||
await Folder.updateAllShareIds(resourceService);
|
||||
await Folder.updateAllShareIds(resourceService, makeStateShares(folder.id));
|
||||
|
||||
const folder2: FolderEntity = await Folder.loadByTitle('folder 2');
|
||||
const note1: NoteEntity = await Note.loadByTitle('note 1');
|
||||
@@ -313,7 +420,7 @@ describe('models/Folder.sharing', () => {
|
||||
|
||||
const previousBlobUpdatedTime = (await Resource.load(resourceId)).blob_updated_time;
|
||||
await msleep(1);
|
||||
await Folder.updateAllShareIds(resourceService);
|
||||
await Folder.updateAllShareIds(resourceService, makeStateShares(folder.id));
|
||||
|
||||
{
|
||||
const resource: ResourceEntity = await Resource.load(resourceId);
|
||||
@@ -324,7 +431,7 @@ describe('models/Folder.sharing', () => {
|
||||
await Note.save({ id: note1.id, parent_id: folder2.id });
|
||||
await resourceService.indexNoteResources();
|
||||
|
||||
await Folder.updateAllShareIds(resourceService);
|
||||
await Folder.updateAllShareIds(resourceService, makeStateShares(folder.id));
|
||||
|
||||
{
|
||||
const resource: ResourceEntity = await Resource.load(resourceId);
|
||||
@@ -392,7 +499,7 @@ describe('models/Folder.sharing', () => {
|
||||
// We need to index the resources to populate the note_resources table
|
||||
|
||||
await resourceService.indexNoteResources();
|
||||
await Folder.updateAllShareIds(resourceService);
|
||||
await Folder.updateAllShareIds(resourceService, makeStateShares(folder1.id, 'share1'));
|
||||
|
||||
// BEFORE:
|
||||
//
|
||||
@@ -464,7 +571,7 @@ describe('models/Folder.sharing', () => {
|
||||
|
||||
await resourceService.indexNoteResources();
|
||||
|
||||
await Folder.updateAllShareIds(resourceService);
|
||||
await Folder.updateAllShareIds(resourceService, makeStateShares([folder1.id, folder2.id], ['1', '2']));
|
||||
|
||||
await Folder.updateNoLongerSharedItems(['1']);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import Note from './Note';
|
||||
import Database from '../database';
|
||||
import BaseItem from './BaseItem';
|
||||
import Resource from './Resource';
|
||||
import { isRootSharedFolder } from '../services/share/reducer';
|
||||
import { isRootSharedFolder, StateShare } from '../services/share/reducer';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import syncDebugLog from '../services/synchronizer/syncDebugLog';
|
||||
import ResourceService from '../services/ResourceService';
|
||||
@@ -357,7 +357,7 @@ export default class Folder extends BaseItem {
|
||||
|
||||
if (options && options.includeConflictFolder) {
|
||||
const conflictCount = await Note.conflictedCount();
|
||||
if (conflictCount) output.push(this.conflictFolder());
|
||||
if (conflictCount) output.unshift(this.conflictFolder());
|
||||
}
|
||||
|
||||
return output;
|
||||
@@ -417,18 +417,75 @@ export default class Folder extends BaseItem {
|
||||
return this.db().selectAll(sql, [folderId]);
|
||||
}
|
||||
|
||||
public static async rootSharedFolders(): Promise<FolderEntity[]> {
|
||||
return this.db().selectAll('SELECT id, share_id FROM folders WHERE parent_id = \'\' AND share_id != \'\'');
|
||||
public static async rootSharedFolders(activeShares: StateShare[]): Promise<FolderEntity[]> {
|
||||
return this.removeDuplicateRootFolders(await this.db().selectAll('SELECT id, share_id FROM folders WHERE parent_id = \'\' AND share_id != \'\''), activeShares);
|
||||
}
|
||||
|
||||
public static async rootShareFoldersByKeyId(keyId: string): Promise<FolderEntity[]> {
|
||||
return this.db().selectAll('SELECT id, share_id FROM folders WHERE master_key_id = ?', [keyId]);
|
||||
}
|
||||
|
||||
public static async updateFolderShareIds(): Promise<void> {
|
||||
// We need this function for this situation:
|
||||
//
|
||||
// - Folder is shared
|
||||
// - Subfolder is created in the shared folder
|
||||
// - Subfolder is moved to the root
|
||||
//
|
||||
// In that situation the subfolder will have "parent_id" = "" and so will be considered a "root
|
||||
// shared folder". However it is not - a "root shared folder" is one that has been explicitly
|
||||
// shared by the user.
|
||||
//
|
||||
// So we have this function to check for root folders that have the same "shared_id" - it
|
||||
// indicates that one of them was a child of the other. We remove the formerly children folders.
|
||||
private static removeDuplicateRootFolders(rootFolders: FolderEntity[], activeShares: StateShare[]) {
|
||||
const folderIdsToRemove: string[] = [];
|
||||
|
||||
for (let i = 0; i < rootFolders.length - 1; i++) {
|
||||
const f1 = rootFolders[i];
|
||||
for (let j = i + 1; j < rootFolders.length; j++) {
|
||||
const f2 = rootFolders[j];
|
||||
|
||||
if (f1.share_id === f2.share_id) {
|
||||
logger.info('Found two root folders with the same share_id:', f1, f2);
|
||||
const share = activeShares.find(s => s.id === f1.share_id);
|
||||
if (!share) {
|
||||
logger.warn('Could not find matching share object');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (share.folder_id === f1.id) {
|
||||
folderIdsToRemove.push(f2.id);
|
||||
} else if (share.folder_id === f2.id) {
|
||||
folderIdsToRemove.push(f1.id);
|
||||
} else {
|
||||
logger.warn('Could not find folder associated with share:', share);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (folderIdsToRemove.length) {
|
||||
logger.info('Removing folders from the list of root folders:', folderIdsToRemove);
|
||||
|
||||
const newRootFolders: FolderEntity[] = [];
|
||||
|
||||
for (const f of rootFolders) {
|
||||
if (!folderIdsToRemove.includes(f.id)) {
|
||||
newRootFolders.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
return newRootFolders;
|
||||
}
|
||||
|
||||
return rootFolders;
|
||||
}
|
||||
|
||||
public static async updateFolderShareIds(activeShares: StateShare[]): Promise<void> {
|
||||
// Get all the sub-folders of the shared folders, and set the share_id
|
||||
// property.
|
||||
const rootFolders = await this.rootSharedFolders();
|
||||
const activeShareIds = activeShares.map(s => s.id);
|
||||
const rootFolders = (await this.rootSharedFolders(activeShares)).filter(f => activeShareIds.includes(f.share_id));
|
||||
|
||||
let sharedFolderIds: string[] = [];
|
||||
|
||||
@@ -482,19 +539,26 @@ export default class Folder extends BaseItem {
|
||||
logger.debug('updateFolderShareIds:', report);
|
||||
}
|
||||
|
||||
public static async updateNoteShareIds() {
|
||||
public static async updateNoteShareIds(activeShares: StateShare[]) {
|
||||
// Find all the notes where the share_id is not the same as the
|
||||
// parent share_id because we only need to update those.
|
||||
const rows = await this.db().selectAll(`
|
||||
const rows1 = await this.db().selectAll(`
|
||||
SELECT notes.id, folders.share_id, notes.parent_id
|
||||
FROM notes
|
||||
LEFT JOIN folders ON notes.parent_id = folders.id
|
||||
WHERE notes.share_id != folders.share_id
|
||||
`);
|
||||
|
||||
logger.debug('updateNoteShareIds: notes to update:', rows.length);
|
||||
const rows2 = await this.db().selectAll(`
|
||||
SELECT notes.id, notes.parent_id
|
||||
FROM notes
|
||||
WHERE notes.share_id != '' AND notes.share_id NOT IN (${BaseModel.escapeIdsForSql(activeShares.map(s => s.id))})
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
logger.debug('updateNoteShareIds: notes to update (1)', rows1);
|
||||
logger.debug('updateNoteShareIds: notes to update (2)', rows2);
|
||||
|
||||
for (const row of rows1.concat(rows2)) {
|
||||
await Note.save({
|
||||
id: row.id,
|
||||
share_id: row.share_id || '',
|
||||
@@ -652,9 +716,9 @@ export default class Folder extends BaseItem {
|
||||
throw new Error('Failed to update resource share IDs');
|
||||
}
|
||||
|
||||
public static async updateAllShareIds(resourceService: ResourceService) {
|
||||
await this.updateFolderShareIds();
|
||||
await this.updateNoteShareIds();
|
||||
public static async updateAllShareIds(resourceService: ResourceService, activeShares: StateShare[]) {
|
||||
await this.updateFolderShareIds(activeShares);
|
||||
await this.updateNoteShareIds(activeShares);
|
||||
await this.updateResourceShareIds(resourceService);
|
||||
}
|
||||
|
||||
|
||||
@@ -456,4 +456,18 @@ describe('models/Setting', () => {
|
||||
await Setting.saveAll();
|
||||
}
|
||||
});
|
||||
|
||||
test('should enforce min and max values for when the setting is already in the cache and when it is not', async () => {
|
||||
await Setting.reset();
|
||||
Setting.setValue('revisionService.ttlDays', 0);
|
||||
expect(Setting.value('revisionService.ttlDays')).toBe(1);
|
||||
Setting.setValue('revisionService.ttlDays', 100000);
|
||||
expect(Setting.value('revisionService.ttlDays')).toBe(99999);
|
||||
|
||||
await Setting.reset();
|
||||
Setting.setValue('revisionService.ttlDays', 100000);
|
||||
expect(Setting.value('revisionService.ttlDays')).toBe(99999);
|
||||
Setting.setValue('revisionService.ttlDays', 0);
|
||||
expect(Setting.value('revisionService.ttlDays')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -657,11 +657,18 @@ class Setting extends BaseModel {
|
||||
value = this.formatValue(key, value);
|
||||
value = this.filterValue(key, value);
|
||||
|
||||
const md = this.settingMetadata(key);
|
||||
const enforceLimits = (value: SettingValueType<T>) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code before rule was applied
|
||||
if ('minimum' in md && value < md.minimum) value = md.minimum as any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code before rule was applied
|
||||
if ('maximum' in md && value > md.maximum) value = md.maximum as any;
|
||||
return value;
|
||||
};
|
||||
|
||||
for (let i = 0; i < this.cache_.length; i++) {
|
||||
const c = this.cache_[i];
|
||||
if (c.key === key) {
|
||||
const md = this.settingMetadata(key);
|
||||
|
||||
if (md.isEnum === true) {
|
||||
if (!this.isAllowedEnumOption(key, value)) {
|
||||
throw new Error(_('Invalid option value: "%s". Possible values are: %s.', value, this.enumOptionsDoc(key)));
|
||||
@@ -675,12 +682,7 @@ class Setting extends BaseModel {
|
||||
// Don't log this to prevent sensitive info (passwords, auth tokens...) to end up in logs
|
||||
// logger.info('Setting: ' + key + ' = ' + c.value + ' => ' + value);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code before rule was applied
|
||||
if ('minimum' in md && value < md.minimum) value = md.minimum as any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code before rule was applied
|
||||
if ('maximum' in md && value > md.maximum) value = md.maximum as any;
|
||||
|
||||
c.value = value;
|
||||
c.value = enforceLimits(value);
|
||||
|
||||
this.dispatch({
|
||||
type: 'SETTING_UPDATE_ONE',
|
||||
@@ -694,6 +696,8 @@ class Setting extends BaseModel {
|
||||
}
|
||||
}
|
||||
|
||||
value = enforceLimits(value);
|
||||
|
||||
this.cache_.push({
|
||||
key: key,
|
||||
value: this.formatValue(key, value),
|
||||
|
||||
@@ -14,6 +14,11 @@ const customCssFilePath = (Setting: typeof SettingType, filename: string): strin
|
||||
return `${Setting.value('rootProfileDir')}/${filename}`;
|
||||
};
|
||||
|
||||
const showVoiceTypingSettings = () => (
|
||||
// For now, iOS and web don't support voice typing.
|
||||
shim.mobilePlatform() === 'android'
|
||||
);
|
||||
|
||||
export enum CameraDirection {
|
||||
Back,
|
||||
Front,
|
||||
@@ -1803,8 +1808,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
appTypes: [AppType.Mobile],
|
||||
description: () => _('Leave it blank to download the language files from the default website'),
|
||||
label: () => _('Voice typing language files (URL)'),
|
||||
// For now, iOS and web don't support voice typing.
|
||||
show: () => shim.mobilePlatform() === 'android',
|
||||
show: showVoiceTypingSettings,
|
||||
section: 'note',
|
||||
},
|
||||
|
||||
@@ -1815,8 +1819,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
appTypes: [AppType.Mobile],
|
||||
label: () => _('Preferred voice typing provider'),
|
||||
isEnum: true,
|
||||
// For now, iOS and web don't support voice typing.
|
||||
show: () => shim.mobilePlatform() === 'android',
|
||||
show: showVoiceTypingSettings,
|
||||
section: 'note',
|
||||
|
||||
options: () => {
|
||||
@@ -1827,6 +1830,17 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
},
|
||||
},
|
||||
|
||||
'voiceTyping.glossary': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
public: true,
|
||||
appTypes: [AppType.Mobile],
|
||||
label: () => _('Voice typing: Glossary'),
|
||||
description: () => _('A comma-separated list of words. May be used for uncommon words, to help voice typing spell them correctly.'),
|
||||
show: (settings) => showVoiceTypingSettings() && settings['voiceTyping.preferredProvider'].startsWith('whisper'),
|
||||
section: 'note',
|
||||
},
|
||||
|
||||
'trash.autoDeletionEnabled': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface WhereQuery {
|
||||
export default async function(db: any, tableName: string, pagination: Pagination, whereQuery: WhereQuery = null, fields: string[] = null): Promise<ModelFeedPage> {
|
||||
fields = fields ? fields.slice() : ['id'];
|
||||
|
||||
const where = whereQuery ? [whereQuery.sql] : [];
|
||||
const where = whereQuery && whereQuery.sql ? [whereQuery.sql] : [];
|
||||
const sqlParams = whereQuery && whereQuery.params ? whereQuery.params.slice() : [];
|
||||
|
||||
if (!pagination.order.length) throw new Error('Pagination order must be provided');
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
|
||||
"generatePluginTypes": "rm -rf ./plugin_types && yarn tsc --declaration --declarationDir ./plugin_types --project tsconfig.json",
|
||||
"test": "jest --verbose=false",
|
||||
"test": "node --security-revert=CVE-2023-46809 ./node_modules/.bin/jest --verbose=false",
|
||||
"test-ci": "yarn test"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -25,14 +25,14 @@
|
||||
"@types/jsdom": "21.1.7",
|
||||
"@types/markdown-it": "13.0.9",
|
||||
"@types/mustache": "4.2.5",
|
||||
"@types/node": "18.19.67",
|
||||
"@types/node": "18.19.86",
|
||||
"@types/node-rsa": "1.1.4",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/uuid": "9.0.7",
|
||||
"@types/react": "18.3.20",
|
||||
"@types/uuid": "10.0.0",
|
||||
"clean-html": "1.5.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-expect-message": "1.1.3",
|
||||
"jsdom": "23.2.0",
|
||||
"jsdom": "25.0.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"react": "18.3.1",
|
||||
"react-test-renderer": "18.3.1",
|
||||
@@ -81,7 +81,7 @@
|
||||
"moment": "2.30.1",
|
||||
"multiparty": "4.2.3",
|
||||
"mustache": "4.2.0",
|
||||
"nanoid": "3.3.10",
|
||||
"nanoid": "3.3.11",
|
||||
"node-fetch": "2.6.7",
|
||||
"node-notifier": "10.0.1",
|
||||
"node-persist": "3.1.3",
|
||||
@@ -101,7 +101,7 @@
|
||||
"tcp-port-used": "1.0.2",
|
||||
"uglifycss": "0.0.29",
|
||||
"url-parse": "1.5.10",
|
||||
"uuid": "9.0.1",
|
||||
"uuid": "11.1.0",
|
||||
"word-wrap": "1.2.5",
|
||||
"xml2js": "0.4.23"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Note from '../../models/Note';
|
||||
import Folder from '../../models/Folder';
|
||||
import { remove, readFile } from 'fs-extra';
|
||||
import { createTempDir, setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../testing/test-utils';
|
||||
import { createTempDir, setupDatabaseAndSynchronizer, supportDir, switchClient, withWarningSilenced } from '../../testing/test-utils';
|
||||
import { NoteEntity } from '../database/types';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import BaseModel from '../../BaseModel';
|
||||
@@ -22,9 +22,7 @@ const expectWithInstructions = <T>(value: T) => {
|
||||
return expect(value, instructionMessage);
|
||||
};
|
||||
|
||||
// We don't require all developers to have Rust to run the project, so we skip this test if not running in CI
|
||||
const skipIfNotCI = process.env.IS_CONTINUOUS_INTEGRATION ? it : it.skip;
|
||||
|
||||
// This file is ignored if not running in CI. Look at onenote-converter/README.md and jest.config.js for more information
|
||||
describe('InteropService_Importer_OneNote', () => {
|
||||
let tempDir: string;
|
||||
async function importNote(path: string) {
|
||||
@@ -52,7 +50,7 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
afterEach(async () => {
|
||||
await remove(tempDir);
|
||||
});
|
||||
skipIfNotCI('should import a simple OneNote notebook', async () => {
|
||||
it('should import a simple OneNote notebook', async () => {
|
||||
const notes = await importNote(`${supportDir}/onenote/simple_notebook.zip`);
|
||||
const folders = await Folder.all();
|
||||
|
||||
@@ -69,7 +67,7 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
expectWithInstructions(mainNote.body).toMatchSnapshot(mainNote.title);
|
||||
});
|
||||
|
||||
skipIfNotCI('should preserve indentation of subpages in Section page', async () => {
|
||||
it('should preserve indentation of subpages in Section page', async () => {
|
||||
const notes = await importNote(`${supportDir}/onenote/subpages.zip`);
|
||||
|
||||
const sectionPage = notes.find(n => n.title === 'Section');
|
||||
@@ -89,7 +87,7 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
expectWithInstructions(menuLines[7].trim()).toBe(`<li class="l2"><a href=":/${pageTwoB.id}" target="content" title="Page 2-b">${pageTwoB.title}</a>`);
|
||||
});
|
||||
|
||||
skipIfNotCI('should created subsections', async () => {
|
||||
it('should created subsections', async () => {
|
||||
const notes = await importNote(`${supportDir}/onenote/subsections.zip`);
|
||||
const folders = await Folder.all();
|
||||
|
||||
@@ -107,7 +105,7 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
expectWithInstructions(notesFromParentSection.length).toBe(2);
|
||||
});
|
||||
|
||||
skipIfNotCI('should expect notes to be rendered the same', async () => {
|
||||
it('should expect notes to be rendered the same', async () => {
|
||||
let idx = 0;
|
||||
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
|
||||
const notes = await importNote(`${supportDir}/onenote/complex_notes.zip`);
|
||||
@@ -124,7 +122,7 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
BaseModel.setIdGenerator(originalIdGenerator);
|
||||
});
|
||||
|
||||
skipIfNotCI('should render the proper tree for notebook with group sections', async () => {
|
||||
it('should render the proper tree for notebook with group sections', async () => {
|
||||
const notes = await importNote(`${supportDir}/onenote/group_sections.zip`);
|
||||
const folders = await Folder.all();
|
||||
|
||||
@@ -152,7 +150,7 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
expectWithInstructions(notes.filter(n => n.parent_id === sectionD1.id).length).toBe(1);
|
||||
});
|
||||
|
||||
skipIfNotCI.each([
|
||||
it.each([
|
||||
'svg_with_text_and_style.html',
|
||||
'many_svgs.html',
|
||||
])('should extract svgs', async (filename: string) => {
|
||||
@@ -179,7 +177,7 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
expectWithInstructions(importer.extractSvgs(content, titleGenerator())).toMatchSnapshot();
|
||||
});
|
||||
|
||||
skipIfNotCI('should ignore broken characters at the start of paragraph', async () => {
|
||||
it('should ignore broken characters at the start of paragraph', async () => {
|
||||
let idx = 0;
|
||||
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
|
||||
const notes = await importNote(`${supportDir}/onenote/bug_broken_character.zip`);
|
||||
@@ -189,7 +187,7 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
BaseModel.setIdGenerator(originalIdGenerator);
|
||||
});
|
||||
|
||||
skipIfNotCI('should remove hyperlink from title', async () => {
|
||||
it('should remove hyperlink from title', async () => {
|
||||
let idx = 0;
|
||||
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
|
||||
const notes = await importNote(`${supportDir}/onenote/remove_hyperlink_on_title.zip`);
|
||||
@@ -200,7 +198,7 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
BaseModel.setIdGenerator(originalIdGenerator);
|
||||
});
|
||||
|
||||
skipIfNotCI('should group link parts even if they have different css styles', async () => {
|
||||
it('should group link parts even if they have different css styles', async () => {
|
||||
const notes = await importNote(`${supportDir}/onenote/remove_hyperlink_on_title.zip`);
|
||||
|
||||
const noteToTest = notes.find(n => n.title === 'Tips from a Pro Using Trees for Dramatic Landscape Photography');
|
||||
@@ -209,7 +207,7 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
expectWithInstructions(noteToTest.body.includes('<a href="onenote:https://d.docs.live.net/c8d3bbab7f1acf3a/Documents/Photography/风景.one#Tips%20from%20a%20Pro%20Using%20Trees%20for%20Dramatic%20Landscape%20Photography§ion-id={262ADDFB-A4DC-4453-A239-0024D6769962}&page-id={88D803A5-4F43-48D4-9B16-4C024F5787DC}&end" style="">Tips from a Pro: Using Trees for Dramatic Landscape Photography</a>')).toBe(true);
|
||||
});
|
||||
|
||||
skipIfNotCI('should render links properly by ignoring wrongly set indices when the first character is a hyperlink marker', async () => {
|
||||
it('should render links properly by ignoring wrongly set indices when the first character is a hyperlink marker', async () => {
|
||||
let idx = 0;
|
||||
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
|
||||
const notes = await importNote(`${supportDir}/onenote/hyperlink_marker_as_first_character.zip`);
|
||||
@@ -220,10 +218,10 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
BaseModel.setIdGenerator(originalIdGenerator);
|
||||
});
|
||||
|
||||
skipIfNotCI('should be able to create notes from corrupted attachment', async () => {
|
||||
it('should be able to create notes from corrupted attachment', async () => {
|
||||
let idx = 0;
|
||||
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
|
||||
const notes = await importNote(`${supportDir}/onenote/corrupted_attachment.zip`);
|
||||
const notes = await withWarningSilenced(/OneNoteConverter:/, async () => importNote(`${supportDir}/onenote/corrupted_attachment.zip`));
|
||||
|
||||
expectWithInstructions(notes.length).toBe(2);
|
||||
|
||||
@@ -233,7 +231,7 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
BaseModel.setIdGenerator(originalIdGenerator);
|
||||
});
|
||||
|
||||
skipIfNotCI('should render audio as links to resource', async () => {
|
||||
it('should render audio as links to resource', async () => {
|
||||
let idx = 0;
|
||||
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
|
||||
const notes = await importNote(`${supportDir}/onenote/note_with_audio_embedded.zip`);
|
||||
@@ -246,10 +244,10 @@ describe('InteropService_Importer_OneNote', () => {
|
||||
BaseModel.setIdGenerator(originalIdGenerator);
|
||||
});
|
||||
|
||||
skipIfNotCI('should use default value for EntityGuid and InkBias if not found', async () => {
|
||||
it('should use default value for EntityGuid and InkBias if not found', async () => {
|
||||
let idx = 0;
|
||||
const originalIdGenerator = BaseModel.setIdGenerator(() => String(idx++));
|
||||
const notes = await importNote(`${supportDir}/onenote/ink_bias_and_entity_guid.zip`);
|
||||
const notes = await withWarningSilenced(/OneNoteConverter:/, async () => importNote(`${supportDir}/onenote/ink_bias_and_entity_guid.zip`));
|
||||
|
||||
// InkBias bug
|
||||
expect(notes.find(n => n.title === 'Marketing Funnel & Training').body).toMatchSnapshot();
|
||||
|
||||
@@ -11,7 +11,7 @@ import NoteTag from '../../models/NoteTag';
|
||||
import ResourceService from '../../services/ResourceService';
|
||||
import SearchEngine from '../search/SearchEngine';
|
||||
const { MarkupToHtml } = require('@joplin/renderer');
|
||||
import { ResourceEntity } from '../database/types';
|
||||
import { NoteEntity, ResourceEntity } from '../database/types';
|
||||
|
||||
const createFolderForPagination = async (num: number, time: number) => {
|
||||
await Folder.save({
|
||||
@@ -961,4 +961,25 @@ describe('services/rest/Api', () => {
|
||||
await SearchEngine.instance().destroy();
|
||||
|
||||
}));
|
||||
|
||||
it('should not fail when both deleted and conflict notes are included', (async () => {
|
||||
const folder = await Folder.save({});
|
||||
const note1 = await Note.save({ parent_id: folder.id });
|
||||
await msleep(1);
|
||||
const note2 = await Note.save({ parent_id: folder.id, deleted_time: 1 });
|
||||
await msleep(1);
|
||||
const note3 = await Note.save({ parent_id: folder.id, is_conflict: 1 });
|
||||
|
||||
const r1 = await api.route(RequestMethod.GET, 'notes', {
|
||||
limit: 3,
|
||||
include_conflicts: '1',
|
||||
include_deleted: '1',
|
||||
});
|
||||
|
||||
expect(r1.items.map((item: NoteEntity) => item.id)).toEqual([
|
||||
note1.id,
|
||||
note2.id,
|
||||
note3.id,
|
||||
]);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -18,6 +18,17 @@ import Setting from '../../models/Setting';
|
||||
import { ModelType } from '../../BaseModel';
|
||||
import { remoteNotesFoldersResources } from '../../testing/test-utils-synchronizer';
|
||||
import mockShareService from '../../testing/share/mockShareService';
|
||||
import { StateShare } from './reducer';
|
||||
|
||||
const makeStateShares = (folderId: string, shareId = 'abcd1234'): StateShare[] => {
|
||||
return [{
|
||||
folder_id: folderId,
|
||||
master_key_id: '',
|
||||
id: shareId,
|
||||
note_id: '',
|
||||
type: 3,
|
||||
}];
|
||||
};
|
||||
|
||||
interface TestShareFolderServiceOptions {
|
||||
master_key_id?: string;
|
||||
@@ -95,7 +106,7 @@ describe('ShareService', () => {
|
||||
|
||||
await service.shareNote(note.id, false);
|
||||
await msleep(1);
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), []);
|
||||
|
||||
await synchronizerStart();
|
||||
|
||||
@@ -157,7 +168,7 @@ describe('ShareService', () => {
|
||||
|
||||
async function testShareFolder(service: ShareService) {
|
||||
const { folder, note, resource } = await prepareNoteFolderResource();
|
||||
const share = await service.shareFolder(folder.id);
|
||||
const share = await service.shareFolder(folder.id, makeStateShares(folder.id, 'share_1'));
|
||||
expect(share.id).toBe('share_1');
|
||||
expect((await Folder.load(folder.id)).share_id).toBe('share_1');
|
||||
expect((await Note.load(note.id)).share_id).toBe('share_1');
|
||||
@@ -185,9 +196,9 @@ describe('ShareService', () => {
|
||||
BaseItem.shareService_ = shareService;
|
||||
Resource.shareService_ = shareService;
|
||||
|
||||
await shareService.shareFolder(folder.id);
|
||||
const share = await shareService.shareFolder(folder.id, makeStateShares(folder.id, 'share_1'));
|
||||
|
||||
await Folder.updateAllShareIds(resourceService());
|
||||
await Folder.updateAllShareIds(resourceService(), makeStateShares(folder.id, share.id));
|
||||
|
||||
// The share service should automatically create a new encryption key
|
||||
// specifically for that shared folder
|
||||
@@ -313,12 +324,12 @@ describe('ShareService', () => {
|
||||
|
||||
const resourceService = new ResourceService();
|
||||
await Folder.save({ id: folder1.id, share_id: '123456789' });
|
||||
await Folder.updateAllShareIds(resourceService);
|
||||
await Folder.updateAllShareIds(resourceService, []);
|
||||
|
||||
const cleanup = simulateReadOnlyShareEnv('123456789');
|
||||
|
||||
const shareService = testShareFolderService();
|
||||
await shareService.leaveSharedFolder(folder1.id, 'somethingrandom');
|
||||
await shareService.leaveSharedFolder(folder1.id, 'somethingrandom', BaseItem.syncShareCache.shares);
|
||||
|
||||
expect(await Folder.count()).toBe(0);
|
||||
expect(await Note.count()).toBe(0);
|
||||
|
||||
@@ -98,7 +98,9 @@ export default class ShareService {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
public async shareFolder(folderId: string): Promise<ApiShare> {
|
||||
public async shareFolder(folderId: string, stateShares: StateShare[]|null = null): Promise<ApiShare> {
|
||||
if (stateShares === null) stateShares = this.shares;
|
||||
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
@@ -137,7 +139,7 @@ export default class ShareService {
|
||||
// Note: race condition if the share is created but the app crashes
|
||||
// before setting share_id on the folder. See unshareFolder() for info.
|
||||
await Folder.save({ id: folder.id, share_id: share.id });
|
||||
await Folder.updateAllShareIds(ResourceService.instance());
|
||||
await Folder.updateAllShareIds(ResourceService.instance(), stateShares);
|
||||
|
||||
return share;
|
||||
}
|
||||
@@ -182,7 +184,7 @@ export default class ShareService {
|
||||
|
||||
// It's ok if updateAllShareIds() doesn't run because it's executed on
|
||||
// each sync too.
|
||||
await Folder.updateAllShareIds(ResourceService.instance());
|
||||
await Folder.updateAllShareIds(ResourceService.instance(), this.shares);
|
||||
}
|
||||
|
||||
// This is when a share recipient decides to leave the shared folder.
|
||||
@@ -206,17 +208,19 @@ export default class ShareService {
|
||||
// If `folderShareUserId` is provided, the function will check that the user
|
||||
// does not own the share. It would be an error to leave such a folder
|
||||
// (instead "unshareFolder" should be called).
|
||||
public async leaveSharedFolder(folderId: string, folderShareUserId: string = null): Promise<void> {
|
||||
public async leaveSharedFolder(folderId: string, folderShareUserId: string = null, stateShares: StateShare[]|null = null): Promise<void> {
|
||||
if (folderShareUserId !== null) {
|
||||
const userId = Setting.value('sync.userId');
|
||||
if (folderShareUserId === userId) throw new Error('Cannot leave own notebook');
|
||||
}
|
||||
|
||||
if (stateShares === null) stateShares = this.shares;
|
||||
|
||||
const folder = await Folder.load(folderId);
|
||||
|
||||
// We call this to make sure all items are correctly linked before we
|
||||
// call deleteAllByShareId()
|
||||
await Folder.updateAllShareIds(ResourceService.instance());
|
||||
await Folder.updateAllShareIds(ResourceService.instance(), stateShares);
|
||||
|
||||
const source = 'ShareService.leaveSharedFolder';
|
||||
await Folder.delete(folderId, { deleteChildren: false, disableReadOnlyCheck: true, sourceDescription: source });
|
||||
@@ -228,7 +232,7 @@ export default class ShareService {
|
||||
// necessary otherwise sync will try to update items that are not longer
|
||||
// accessible and will throw the error "Could not find share with ID: xxxx")
|
||||
public async checkShareConsistency() {
|
||||
const rootSharedFolders = await Folder.rootSharedFolders();
|
||||
const rootSharedFolders = await Folder.rootSharedFolders(this.shares);
|
||||
let hasRefreshedShares = false;
|
||||
let shares = this.shares;
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ function setupProxySettings(options: any) {
|
||||
proxySettings.proxyUrl = options.proxyUrl;
|
||||
}
|
||||
|
||||
interface ShimInitOptions {
|
||||
export interface ShimInitOptions {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
sharp: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
|
||||
@@ -3,20 +3,33 @@ import reducer, { State, defaultState } from '../../reducer';
|
||||
import ShareService from '../../services/share/ShareService';
|
||||
import { encryptionService } from '../test-utils';
|
||||
import JoplinServerApi, { ExecOptions } from '../../JoplinServerApi';
|
||||
import { ShareInvitation, StateShare } from '../../services/share/reducer';
|
||||
import { ShareInvitation, StateShare, StateShareUser } from '../../services/share/reducer';
|
||||
|
||||
const testReducer = (state = defaultState, action: unknown) => {
|
||||
return reducer(state, action);
|
||||
};
|
||||
|
||||
type Query = Record<string, unknown>;
|
||||
type OnShareGetListener = (query: Query)=> Promise<{ items: Partial<StateShare>[] }>;
|
||||
type OnSharePostListener = (query: Query)=> Promise<{ id: string }>;
|
||||
type OnInvitationGetListener = (query: Query)=> Promise<{ items: Partial<ShareInvitation>[] }>;
|
||||
interface ShareStateResponse {
|
||||
items: Partial<StateShare>[];
|
||||
}
|
||||
interface ShareInvitationResponse {
|
||||
items: Partial<ShareInvitation>[];
|
||||
}
|
||||
interface ShareUsersResponse {
|
||||
items: Partial<StateShareUser>[];
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
type OnShareGetListener = (query: Json)=> Promise<ShareStateResponse>;
|
||||
type OnSharePostListener = (query: Json)=> Promise<{ id: string }>;
|
||||
type OnInvitationGetListener = (query: Json)=> Promise<ShareInvitationResponse>;
|
||||
type OnShareUsersGetListener = (shareId: string)=> Promise<ShareUsersResponse>;
|
||||
type OnShareUsersPostListener = (shareId: string, body: Json)=> Promise<void>;
|
||||
|
||||
type OnApiExecListener = (
|
||||
method: string,
|
||||
path: string,
|
||||
query: Query,
|
||||
query: Json,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needs to interface with old code from before the rule was applied
|
||||
body: any,
|
||||
headers: Record<string, unknown>,
|
||||
@@ -27,6 +40,8 @@ export type ApiMock = {
|
||||
getShares: OnShareGetListener;
|
||||
postShares: OnSharePostListener;
|
||||
getShareInvitations: OnInvitationGetListener;
|
||||
getShareUsers?: OnShareUsersGetListener;
|
||||
postShareUsers?: OnShareUsersPostListener;
|
||||
onUnhandled?: OnApiExecListener;
|
||||
|
||||
onExec?: undefined;
|
||||
@@ -37,6 +52,8 @@ export type ApiMock = {
|
||||
getShareInvitations?: undefined;
|
||||
getShares?: undefined;
|
||||
postShares?: undefined;
|
||||
getShareUsers?: undefined;
|
||||
postShareUsers?: undefined;
|
||||
};
|
||||
|
||||
// Initializes a share service with mocks
|
||||
@@ -57,6 +74,16 @@ const mockShareService = (apiCallHandler: ApiMock, service?: ShareService, store
|
||||
return apiCallHandler.getShareInvitations(query);
|
||||
}
|
||||
|
||||
const shareUsersMatch = path.match(/^api\/shares\/([^/]+)\/users$/);
|
||||
const shareId = shareUsersMatch?.[1];
|
||||
if (shareId) {
|
||||
if (method === 'GET' && apiCallHandler.getShareUsers) {
|
||||
return apiCallHandler.getShareUsers(shareId);
|
||||
}
|
||||
if (method === 'POST' && apiCallHandler.postShareUsers) {
|
||||
return apiCallHandler.postShareUsers(shareId, body);
|
||||
}
|
||||
}
|
||||
|
||||
if (apiCallHandler.onUnhandled) {
|
||||
return apiCallHandler.onUnhandled(method, path, query, body, headers, options);
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum PlanName {
|
||||
Basic = 'basic',
|
||||
Pro = 'pro',
|
||||
Teams = 'teams',
|
||||
JoplinServerBusiness = 'joplinServerBusiness',
|
||||
}
|
||||
|
||||
interface PlanFeature {
|
||||
@@ -17,6 +18,7 @@ interface PlanFeature {
|
||||
basic: boolean;
|
||||
pro: boolean;
|
||||
teams: boolean;
|
||||
joplinServerBusiness?: boolean;
|
||||
basicInfo?: string;
|
||||
proInfo?: string;
|
||||
teamsInfo?: string;
|
||||
@@ -25,11 +27,16 @@ interface PlanFeature {
|
||||
teamsInfoShort?: string;
|
||||
}
|
||||
|
||||
enum PlanHostingType {
|
||||
Managed = 'managed',
|
||||
Self = 'self',
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
name: string;
|
||||
title: string;
|
||||
priceMonthly: StripePublicConfigPrice;
|
||||
priceYearly: StripePublicConfigPrice;
|
||||
priceMonthly?: StripePublicConfigPrice;
|
||||
priceYearly?: StripePublicConfigPrice;
|
||||
featured: boolean;
|
||||
iconName: string;
|
||||
featuresOn: FeatureId[];
|
||||
@@ -39,6 +46,8 @@ export interface Plan {
|
||||
cfaLabel: string;
|
||||
cfaUrl: string;
|
||||
footnote: string;
|
||||
learnMoreUrl?: string;
|
||||
hostingType: PlanHostingType;
|
||||
}
|
||||
|
||||
export enum PricePeriod {
|
||||
@@ -155,26 +164,29 @@ const features = (): Record<FeatureId, PlanFeature> => {
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
joplinServerBusiness: true,
|
||||
},
|
||||
sync: {
|
||||
title: _('Sync as many devices as you want'),
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
joplinServerBusiness: true,
|
||||
},
|
||||
clipper: {
|
||||
title: _('Web Clipper'),
|
||||
description: _('The [Web Clipper](%s) is a browser extension that allows you to save web pages and screenshots from your browser.', 'https://joplinapp.org/help/apps/clipper'),
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
},
|
||||
// clipper: {
|
||||
// title: _('Web Clipper'),
|
||||
// description: _('The [Web Clipper](%s) is a browser extension that allows you to save web pages and screenshots from your browser.', 'https://joplinapp.org/help/apps/clipper'),
|
||||
// basic: false,
|
||||
// pro: false,
|
||||
// teams: false,
|
||||
// },
|
||||
collaborate: {
|
||||
title: _('Collaborate on a notebook with others'),
|
||||
description: _('This allows another user to share a notebook with you, and you can then both collaborate on it. It does not however allow you to share a notebook with someone else, unless you have the feature "%s".', shareNotebookTitle),
|
||||
basic: true,
|
||||
pro: true,
|
||||
teams: true,
|
||||
joplinServerBusiness: true,
|
||||
},
|
||||
share: {
|
||||
title: shareNotebookTitle,
|
||||
@@ -182,6 +194,7 @@ const features = (): Record<FeatureId, PlanFeature> => {
|
||||
basic: false,
|
||||
pro: true,
|
||||
teams: true,
|
||||
joplinServerBusiness: true,
|
||||
},
|
||||
emailToNote: {
|
||||
title: _('Email to Note'),
|
||||
@@ -189,6 +202,7 @@ const features = (): Record<FeatureId, PlanFeature> => {
|
||||
basic: false,
|
||||
pro: true,
|
||||
teams: true,
|
||||
joplinServerBusiness: true,
|
||||
},
|
||||
customBanner: {
|
||||
title: _('Customise the note publishing banner'),
|
||||
@@ -196,6 +210,7 @@ const features = (): Record<FeatureId, PlanFeature> => {
|
||||
basic: false,
|
||||
pro: true,
|
||||
teams: true,
|
||||
joplinServerBusiness: true,
|
||||
},
|
||||
multiUsers: {
|
||||
title: _('Manage multiple users'),
|
||||
@@ -203,6 +218,7 @@ const features = (): Record<FeatureId, PlanFeature> => {
|
||||
basic: false,
|
||||
pro: false,
|
||||
teams: true,
|
||||
joplinServerBusiness: true,
|
||||
},
|
||||
consolidatedBilling: {
|
||||
title: _('Consolidated billing'),
|
||||
@@ -217,12 +233,28 @@ const features = (): Record<FeatureId, PlanFeature> => {
|
||||
basic: false,
|
||||
pro: false,
|
||||
teams: true,
|
||||
joplinServerBusiness: true,
|
||||
},
|
||||
prioritySupport: {
|
||||
title: _('Priority support'),
|
||||
basic: false,
|
||||
pro: false,
|
||||
teams: true,
|
||||
joplinServerBusiness: true,
|
||||
},
|
||||
selfHosted: {
|
||||
title: _('Self-hosted'),
|
||||
basic: false,
|
||||
pro: false,
|
||||
teams: false,
|
||||
joplinServerBusiness: true,
|
||||
},
|
||||
sourceCodeAvailable: {
|
||||
title: _('Source code available'),
|
||||
basic: false,
|
||||
pro: false,
|
||||
teams: false,
|
||||
joplinServerBusiness: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -303,6 +335,11 @@ export const createFeatureTableMd = () => {
|
||||
name: 'teams',
|
||||
label: 'Teams',
|
||||
},
|
||||
{
|
||||
name: 'joplinServerBusiness',
|
||||
label: 'Joplin Server Business',
|
||||
labelUrl: 'https://joplinapp.org/help/apps/joplin_server_business',
|
||||
},
|
||||
];
|
||||
|
||||
const rows: MarkdownTableRow[] = [];
|
||||
@@ -332,6 +369,7 @@ export const createFeatureTableMd = () => {
|
||||
basic: getCellInfo(PlanName.Basic, feature),
|
||||
pro: getCellInfo(PlanName.Pro, feature),
|
||||
teams: getCellInfo(PlanName.Teams, feature),
|
||||
joplinServerBusiness: getCellInfo(PlanName.JoplinServerBusiness, feature),
|
||||
};
|
||||
|
||||
rows.push(row);
|
||||
@@ -362,6 +400,7 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla
|
||||
cfaLabel: _('Try it now'),
|
||||
cfaUrl: '',
|
||||
footnote: '',
|
||||
hostingType: PlanHostingType.Managed,
|
||||
},
|
||||
|
||||
pro: {
|
||||
@@ -384,6 +423,7 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla
|
||||
cfaLabel: _('Try it now'),
|
||||
cfaUrl: '',
|
||||
footnote: '',
|
||||
hostingType: PlanHostingType.Managed,
|
||||
},
|
||||
|
||||
teams: {
|
||||
@@ -406,6 +446,23 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla
|
||||
cfaLabel: _('Try it now'),
|
||||
cfaUrl: '',
|
||||
footnote: _('Per user. Minimum of 2 users.'),
|
||||
hostingType: PlanHostingType.Managed,
|
||||
},
|
||||
|
||||
joplinServerBusiness: {
|
||||
name: 'joplinServerBusiness',
|
||||
title: _('Joplin Server Business'),
|
||||
featured: false,
|
||||
iconName: 'business-icon',
|
||||
featuresOn: getFeatureIdsByPlan(PlanName.JoplinServerBusiness, true),
|
||||
featuresOff: getFeatureIdsByPlan(PlanName.JoplinServerBusiness, false),
|
||||
featureLabelsOn: getFeatureLabelsByPlan(PlanName.JoplinServerBusiness, true),
|
||||
featureLabelsOff: getFeatureLabelsByPlan(PlanName.JoplinServerBusiness, false),
|
||||
cfaLabel: _('Get a quote'),
|
||||
cfaUrl: 'mailto:jsb-inquiry@joplin.cloud?subject=Joplin%20Server%20Business%20inquiry',
|
||||
footnote: '',
|
||||
learnMoreUrl: 'https://joplinapp.org/help/apps/joplin_server_business',
|
||||
hostingType: PlanHostingType.Self,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||