mirror of https://github.com/laurent22/joplin.git
Compare commits
90 Commits
Author | SHA1 | Date |
---|---|---|
|
537543cc8a | |
|
ff16453299 | |
|
210deec495 | |
|
e96baea005 | |
|
ae24b91f25 | |
|
f2e5118bf5 | |
|
72698ec573 | |
|
68abc27c6a | |
|
1acb3d0726 | |
|
5bf97dc3b8 | |
|
e0e04fbc91 | |
|
625cd1221c | |
|
110d5bde2d | |
|
93a85b3207 | |
|
ff305f42fd | |
|
99ba854ee1 | |
|
38b368e997 | |
|
f9ffe6c4e6 | |
|
5adc0170fc | |
|
f54c364b4d | |
|
9f541b9b9d | |
|
bd0af08c57 | |
|
ac06c6750d | |
|
23b07094b7 | |
|
7eefc016de | |
|
c002be76cd | |
|
2cd29aaaea | |
|
4cb6b01c71 | |
|
91c79b9488 | |
|
fc516d05b3 | |
|
2769c9586c | |
|
fd15d5a6d3 | |
|
7237d7faa7 | |
|
3025d62568 | |
|
5b5dcf34a1 | |
|
9e8500c148 | |
|
4f1999f921 | |
|
6ee9571069 | |
|
10663b1494 | |
|
f25db9bbd7 | |
|
eac995a209 | |
|
15c973e885 | |
|
1762f9485f | |
|
7777f8428f | |
|
948aa9db4f | |
|
fdde04ee85 | |
|
f77a20f5d5 | |
|
d43aa2a3e6 | |
|
04d5ce13c2 | |
|
3b764ba06a | |
|
5492ce55fa | |
|
f6b3f9860c | |
|
88f687ba6a | |
|
1f0a98999f | |
|
69135c3bea | |
|
c27d542a4b | |
|
bd1c2534c5 | |
|
72513b520c | |
|
ec0f9ef9bc | |
|
818bc3218a | |
|
82760a5b6a | |
|
5ba9a16cfd | |
|
68fc91fdc7 | |
|
bdc4687327 | |
|
5e1909cee0 | |
|
2e7b312415 | |
|
7735a59fc1 | |
|
41d6e912a7 | |
|
4c2fae8423 | |
|
b72c134890 | |
|
58a9c229bb | |
|
d8c203bb8a | |
|
9020c07825 | |
|
d134ea8bfe | |
|
faa44468f3 | |
|
b9c5b8f187 | |
|
da8e638359 | |
|
56ed471a2f | |
|
650594ecea | |
|
78fb07d4c7 | |
|
78c5c4d7c3 | |
|
57093b35ea | |
|
bc2832e78f | |
|
424cc96d36 | |
|
56fd5d828f | |
|
03843b087a | |
|
f6851314d2 | |
|
eaec45cb3f | |
|
9be954496c | |
|
98ef5e619b |
|
@ -96,6 +96,7 @@ packages/onenote-converter/pkg/onenote_converter.js
|
||||||
packages/app-cli/app/LinkSelector.js
|
packages/app-cli/app/LinkSelector.js
|
||||||
packages/app-cli/app/app.js
|
packages/app-cli/app/app.js
|
||||||
packages/app-cli/app/base-command.js
|
packages/app-cli/app/base-command.js
|
||||||
|
packages/app-cli/app/cli-integration-tests.js
|
||||||
packages/app-cli/app/command-apidoc.js
|
packages/app-cli/app/command-apidoc.js
|
||||||
packages/app-cli/app/command-attach.js
|
packages/app-cli/app/command-attach.js
|
||||||
packages/app-cli/app/command-batch.js
|
packages/app-cli/app/command-batch.js
|
||||||
|
@ -676,6 +677,8 @@ packages/app-mobile/components/ExtendedWebView/index.js
|
||||||
packages/app-mobile/components/ExtendedWebView/index.web.js
|
packages/app-mobile/components/ExtendedWebView/index.web.js
|
||||||
packages/app-mobile/components/ExtendedWebView/types.js
|
packages/app-mobile/components/ExtendedWebView/types.js
|
||||||
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
||||||
|
packages/app-mobile/components/FeedbackBanner.test.js
|
||||||
|
packages/app-mobile/components/FeedbackBanner.js
|
||||||
packages/app-mobile/components/FolderPicker.js
|
packages/app-mobile/components/FolderPicker.js
|
||||||
packages/app-mobile/components/Icon.js
|
packages/app-mobile/components/Icon.js
|
||||||
packages/app-mobile/components/IconButton.js
|
packages/app-mobile/components/IconButton.js
|
||||||
|
@ -813,7 +816,6 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButto
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js
|
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js
|
||||||
|
@ -901,14 +903,13 @@ packages/app-mobile/services/AlarmServiceDriver.web.js
|
||||||
packages/app-mobile/services/BackButtonService.js
|
packages/app-mobile/services/BackButtonService.js
|
||||||
packages/app-mobile/services/commands/stateToWhenClauseContext.js
|
packages/app-mobile/services/commands/stateToWhenClauseContext.js
|
||||||
packages/app-mobile/services/e2ee/RSA.react-native.js
|
packages/app-mobile/services/e2ee/RSA.react-native.js
|
||||||
|
packages/app-mobile/services/e2ee/RSA.react-native.web.js
|
||||||
packages/app-mobile/services/e2ee/crypto.js
|
packages/app-mobile/services/e2ee/crypto.js
|
||||||
packages/app-mobile/services/plugins/PlatformImplementation.js
|
packages/app-mobile/services/plugins/PlatformImplementation.js
|
||||||
packages/app-mobile/services/profiles/index.js
|
packages/app-mobile/services/profiles/index.js
|
||||||
packages/app-mobile/services/voiceTyping/VoiceTyping.js
|
packages/app-mobile/services/voiceTyping/VoiceTyping.js
|
||||||
packages/app-mobile/services/voiceTyping/utils/unzip.android.js
|
packages/app-mobile/services/voiceTyping/utils/unzip.android.js
|
||||||
packages/app-mobile/services/voiceTyping/utils/unzip.js
|
packages/app-mobile/services/voiceTyping/utils/unzip.js
|
||||||
packages/app-mobile/services/voiceTyping/vosk.android.js
|
|
||||||
packages/app-mobile/services/voiceTyping/vosk.js
|
|
||||||
packages/app-mobile/services/voiceTyping/whisper.test.js
|
packages/app-mobile/services/voiceTyping/whisper.test.js
|
||||||
packages/app-mobile/services/voiceTyping/whisper.js
|
packages/app-mobile/services/voiceTyping/whisper.js
|
||||||
packages/app-mobile/setupQuickActions.js
|
packages/app-mobile/setupQuickActions.js
|
||||||
|
@ -922,6 +923,7 @@ packages/app-mobile/utils/ShareUtils.test.js
|
||||||
packages/app-mobile/utils/ShareUtils.js
|
packages/app-mobile/utils/ShareUtils.js
|
||||||
packages/app-mobile/utils/TlsUtils.js
|
packages/app-mobile/utils/TlsUtils.js
|
||||||
packages/app-mobile/utils/appDefaultState.js
|
packages/app-mobile/utils/appDefaultState.js
|
||||||
|
packages/app-mobile/utils/appReducer.js
|
||||||
packages/app-mobile/utils/autodetectTheme.js
|
packages/app-mobile/utils/autodetectTheme.js
|
||||||
packages/app-mobile/utils/buildStartupTasks.js
|
packages/app-mobile/utils/buildStartupTasks.js
|
||||||
packages/app-mobile/utils/checkPermissions.js
|
packages/app-mobile/utils/checkPermissions.js
|
||||||
|
@ -961,6 +963,7 @@ packages/app-mobile/utils/pickDocument.js
|
||||||
packages/app-mobile/utils/polyfills/bufferPolyfill.js
|
packages/app-mobile/utils/polyfills/bufferPolyfill.js
|
||||||
packages/app-mobile/utils/polyfills/crypto-polyfill/index.js
|
packages/app-mobile/utils/polyfills/crypto-polyfill/index.js
|
||||||
packages/app-mobile/utils/polyfills/index.js
|
packages/app-mobile/utils/polyfills/index.js
|
||||||
|
packages/app-mobile/utils/polyfills/index.web.js
|
||||||
packages/app-mobile/utils/setupNotifications.js
|
packages/app-mobile/utils/setupNotifications.js
|
||||||
packages/app-mobile/utils/shareFile.js
|
packages/app-mobile/utils/shareFile.js
|
||||||
packages/app-mobile/utils/shareHandler.js
|
packages/app-mobile/utils/shareHandler.js
|
||||||
|
@ -971,6 +974,7 @@ packages/app-mobile/utils/shim-init-react/shimInitShared.js
|
||||||
packages/app-mobile/utils/testing/createMockReduxStore.js
|
packages/app-mobile/utils/testing/createMockReduxStore.js
|
||||||
packages/app-mobile/utils/testing/getWebViewDomById.js
|
packages/app-mobile/utils/testing/getWebViewDomById.js
|
||||||
packages/app-mobile/utils/testing/getWebViewWindowById.js
|
packages/app-mobile/utils/testing/getWebViewWindowById.js
|
||||||
|
packages/app-mobile/utils/testing/mockPluginServiceSetup.js
|
||||||
packages/app-mobile/utils/testing/setupGlobalStore.js
|
packages/app-mobile/utils/testing/setupGlobalStore.js
|
||||||
packages/app-mobile/utils/testing/testingLibrary.js
|
packages/app-mobile/utils/testing/testingLibrary.js
|
||||||
packages/app-mobile/utils/types.js
|
packages/app-mobile/utils/types.js
|
||||||
|
@ -1404,14 +1408,19 @@ packages/lib/services/database/types.js
|
||||||
packages/lib/services/debug/populateDatabase.js
|
packages/lib/services/debug/populateDatabase.js
|
||||||
packages/lib/services/e2ee/EncryptionService.test.js
|
packages/lib/services/e2ee/EncryptionService.test.js
|
||||||
packages/lib/services/e2ee/EncryptionService.js
|
packages/lib/services/e2ee/EncryptionService.js
|
||||||
packages/lib/services/e2ee/RSA.node.js
|
|
||||||
packages/lib/services/e2ee/crypto.test.js
|
packages/lib/services/e2ee/crypto.test.js
|
||||||
packages/lib/services/e2ee/crypto.js
|
packages/lib/services/e2ee/crypto.js
|
||||||
packages/lib/services/e2ee/cryptoShared.js
|
packages/lib/services/e2ee/cryptoShared.js
|
||||||
packages/lib/services/e2ee/cryptoTestUtils.js
|
packages/lib/services/e2ee/cryptoTestUtils.js
|
||||||
packages/lib/services/e2ee/ppk.test.js
|
packages/lib/services/e2ee/ppk/RSA.node.js
|
||||||
packages/lib/services/e2ee/ppk.js
|
packages/lib/services/e2ee/ppk/ppk.test.js
|
||||||
packages/lib/services/e2ee/ppkTestUtils.js
|
packages/lib/services/e2ee/ppk/ppk.js
|
||||||
|
packages/lib/services/e2ee/ppk/ppkTestUtils.js
|
||||||
|
packages/lib/services/e2ee/ppk/webCrypto/LongDataWrapper.js
|
||||||
|
packages/lib/services/e2ee/ppk/webCrypto/StringToBufferWrapper.js
|
||||||
|
packages/lib/services/e2ee/ppk/webCrypto/WebCryptoRsa.js
|
||||||
|
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.test.js
|
||||||
|
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
|
||||||
packages/lib/services/e2ee/types.js
|
packages/lib/services/e2ee/types.js
|
||||||
packages/lib/services/e2ee/utils.test.js
|
packages/lib/services/e2ee/utils.test.js
|
||||||
packages/lib/services/e2ee/utils.js
|
packages/lib/services/e2ee/utils.js
|
||||||
|
|
|
@ -41,3 +41,28 @@ jobs:
|
||||||
sed -i -- 's/signingConfig signingConfigs.release/signingConfig signingConfigs.debug/' app/build.gradle
|
sed -i -- 's/signingConfig signingConfigs.release/signingConfig signingConfigs.debug/' app/build.gradle
|
||||||
./gradlew assembleRelease
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
- name: Verify alignment
|
||||||
|
run: |
|
||||||
|
cd packages/app-mobile/android/app
|
||||||
|
APK_FILE="./build/outputs/apk/release/app-release.apk"
|
||||||
|
if test ! -f "$APK_FILE" ; then
|
||||||
|
echo "APK file not found."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "APK file found at: $APK_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUILD_TOOLS_PATH="$ANDROID_HOME/build-tools/"
|
||||||
|
if test ! -d "$BUILD_TOOLS_PATH" ; then
|
||||||
|
echo "Build tools not found at $BUILD_TOOLS_PATH ($ANDROID_HOME, $BUILD_TOOLS_VERSION)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# The build-tools/ directory contains different subdirectories
|
||||||
|
# for each build tools version. As a result, there may be multiple
|
||||||
|
# zipalign tools. Select one of them:
|
||||||
|
ZIPALIGN_PATH="$(find $BUILD_TOOLS_PATH -name "zipalign" -print | head -n1)"
|
||||||
|
if test ! -x "$ZIPALIGN_PATH" ; then
|
||||||
|
echo "zipalign not found (searching in $BUILD_TOOLS_PATH, candidate: $ZIPALIGN_PATH)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"$ZIPALIGN_PATH" -c -P 16 -v 4 "$APK_FILE"
|
|
@ -69,6 +69,7 @@ docs/**/*.mustache
|
||||||
packages/app-cli/app/LinkSelector.js
|
packages/app-cli/app/LinkSelector.js
|
||||||
packages/app-cli/app/app.js
|
packages/app-cli/app/app.js
|
||||||
packages/app-cli/app/base-command.js
|
packages/app-cli/app/base-command.js
|
||||||
|
packages/app-cli/app/cli-integration-tests.js
|
||||||
packages/app-cli/app/command-apidoc.js
|
packages/app-cli/app/command-apidoc.js
|
||||||
packages/app-cli/app/command-attach.js
|
packages/app-cli/app/command-attach.js
|
||||||
packages/app-cli/app/command-batch.js
|
packages/app-cli/app/command-batch.js
|
||||||
|
@ -649,6 +650,8 @@ packages/app-mobile/components/ExtendedWebView/index.js
|
||||||
packages/app-mobile/components/ExtendedWebView/index.web.js
|
packages/app-mobile/components/ExtendedWebView/index.web.js
|
||||||
packages/app-mobile/components/ExtendedWebView/types.js
|
packages/app-mobile/components/ExtendedWebView/types.js
|
||||||
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
||||||
|
packages/app-mobile/components/FeedbackBanner.test.js
|
||||||
|
packages/app-mobile/components/FeedbackBanner.js
|
||||||
packages/app-mobile/components/FolderPicker.js
|
packages/app-mobile/components/FolderPicker.js
|
||||||
packages/app-mobile/components/Icon.js
|
packages/app-mobile/components/Icon.js
|
||||||
packages/app-mobile/components/IconButton.js
|
packages/app-mobile/components/IconButton.js
|
||||||
|
@ -786,7 +789,6 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButto
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js
|
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js
|
||||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js
|
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js
|
||||||
|
@ -874,14 +876,13 @@ packages/app-mobile/services/AlarmServiceDriver.web.js
|
||||||
packages/app-mobile/services/BackButtonService.js
|
packages/app-mobile/services/BackButtonService.js
|
||||||
packages/app-mobile/services/commands/stateToWhenClauseContext.js
|
packages/app-mobile/services/commands/stateToWhenClauseContext.js
|
||||||
packages/app-mobile/services/e2ee/RSA.react-native.js
|
packages/app-mobile/services/e2ee/RSA.react-native.js
|
||||||
|
packages/app-mobile/services/e2ee/RSA.react-native.web.js
|
||||||
packages/app-mobile/services/e2ee/crypto.js
|
packages/app-mobile/services/e2ee/crypto.js
|
||||||
packages/app-mobile/services/plugins/PlatformImplementation.js
|
packages/app-mobile/services/plugins/PlatformImplementation.js
|
||||||
packages/app-mobile/services/profiles/index.js
|
packages/app-mobile/services/profiles/index.js
|
||||||
packages/app-mobile/services/voiceTyping/VoiceTyping.js
|
packages/app-mobile/services/voiceTyping/VoiceTyping.js
|
||||||
packages/app-mobile/services/voiceTyping/utils/unzip.android.js
|
packages/app-mobile/services/voiceTyping/utils/unzip.android.js
|
||||||
packages/app-mobile/services/voiceTyping/utils/unzip.js
|
packages/app-mobile/services/voiceTyping/utils/unzip.js
|
||||||
packages/app-mobile/services/voiceTyping/vosk.android.js
|
|
||||||
packages/app-mobile/services/voiceTyping/vosk.js
|
|
||||||
packages/app-mobile/services/voiceTyping/whisper.test.js
|
packages/app-mobile/services/voiceTyping/whisper.test.js
|
||||||
packages/app-mobile/services/voiceTyping/whisper.js
|
packages/app-mobile/services/voiceTyping/whisper.js
|
||||||
packages/app-mobile/setupQuickActions.js
|
packages/app-mobile/setupQuickActions.js
|
||||||
|
@ -895,6 +896,7 @@ packages/app-mobile/utils/ShareUtils.test.js
|
||||||
packages/app-mobile/utils/ShareUtils.js
|
packages/app-mobile/utils/ShareUtils.js
|
||||||
packages/app-mobile/utils/TlsUtils.js
|
packages/app-mobile/utils/TlsUtils.js
|
||||||
packages/app-mobile/utils/appDefaultState.js
|
packages/app-mobile/utils/appDefaultState.js
|
||||||
|
packages/app-mobile/utils/appReducer.js
|
||||||
packages/app-mobile/utils/autodetectTheme.js
|
packages/app-mobile/utils/autodetectTheme.js
|
||||||
packages/app-mobile/utils/buildStartupTasks.js
|
packages/app-mobile/utils/buildStartupTasks.js
|
||||||
packages/app-mobile/utils/checkPermissions.js
|
packages/app-mobile/utils/checkPermissions.js
|
||||||
|
@ -934,6 +936,7 @@ packages/app-mobile/utils/pickDocument.js
|
||||||
packages/app-mobile/utils/polyfills/bufferPolyfill.js
|
packages/app-mobile/utils/polyfills/bufferPolyfill.js
|
||||||
packages/app-mobile/utils/polyfills/crypto-polyfill/index.js
|
packages/app-mobile/utils/polyfills/crypto-polyfill/index.js
|
||||||
packages/app-mobile/utils/polyfills/index.js
|
packages/app-mobile/utils/polyfills/index.js
|
||||||
|
packages/app-mobile/utils/polyfills/index.web.js
|
||||||
packages/app-mobile/utils/setupNotifications.js
|
packages/app-mobile/utils/setupNotifications.js
|
||||||
packages/app-mobile/utils/shareFile.js
|
packages/app-mobile/utils/shareFile.js
|
||||||
packages/app-mobile/utils/shareHandler.js
|
packages/app-mobile/utils/shareHandler.js
|
||||||
|
@ -944,6 +947,7 @@ packages/app-mobile/utils/shim-init-react/shimInitShared.js
|
||||||
packages/app-mobile/utils/testing/createMockReduxStore.js
|
packages/app-mobile/utils/testing/createMockReduxStore.js
|
||||||
packages/app-mobile/utils/testing/getWebViewDomById.js
|
packages/app-mobile/utils/testing/getWebViewDomById.js
|
||||||
packages/app-mobile/utils/testing/getWebViewWindowById.js
|
packages/app-mobile/utils/testing/getWebViewWindowById.js
|
||||||
|
packages/app-mobile/utils/testing/mockPluginServiceSetup.js
|
||||||
packages/app-mobile/utils/testing/setupGlobalStore.js
|
packages/app-mobile/utils/testing/setupGlobalStore.js
|
||||||
packages/app-mobile/utils/testing/testingLibrary.js
|
packages/app-mobile/utils/testing/testingLibrary.js
|
||||||
packages/app-mobile/utils/types.js
|
packages/app-mobile/utils/types.js
|
||||||
|
@ -1377,14 +1381,19 @@ packages/lib/services/database/types.js
|
||||||
packages/lib/services/debug/populateDatabase.js
|
packages/lib/services/debug/populateDatabase.js
|
||||||
packages/lib/services/e2ee/EncryptionService.test.js
|
packages/lib/services/e2ee/EncryptionService.test.js
|
||||||
packages/lib/services/e2ee/EncryptionService.js
|
packages/lib/services/e2ee/EncryptionService.js
|
||||||
packages/lib/services/e2ee/RSA.node.js
|
|
||||||
packages/lib/services/e2ee/crypto.test.js
|
packages/lib/services/e2ee/crypto.test.js
|
||||||
packages/lib/services/e2ee/crypto.js
|
packages/lib/services/e2ee/crypto.js
|
||||||
packages/lib/services/e2ee/cryptoShared.js
|
packages/lib/services/e2ee/cryptoShared.js
|
||||||
packages/lib/services/e2ee/cryptoTestUtils.js
|
packages/lib/services/e2ee/cryptoTestUtils.js
|
||||||
packages/lib/services/e2ee/ppk.test.js
|
packages/lib/services/e2ee/ppk/RSA.node.js
|
||||||
packages/lib/services/e2ee/ppk.js
|
packages/lib/services/e2ee/ppk/ppk.test.js
|
||||||
packages/lib/services/e2ee/ppkTestUtils.js
|
packages/lib/services/e2ee/ppk/ppk.js
|
||||||
|
packages/lib/services/e2ee/ppk/ppkTestUtils.js
|
||||||
|
packages/lib/services/e2ee/ppk/webCrypto/LongDataWrapper.js
|
||||||
|
packages/lib/services/e2ee/ppk/webCrypto/StringToBufferWrapper.js
|
||||||
|
packages/lib/services/e2ee/ppk/webCrypto/WebCryptoRsa.js
|
||||||
|
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.test.js
|
||||||
|
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
|
||||||
packages/lib/services/e2ee/types.js
|
packages/lib/services/e2ee/types.js
|
||||||
packages/lib/services/e2ee/utils.test.js
|
packages/lib/services/e2ee/utils.test.js
|
||||||
packages/lib/services/e2ee/utils.js
|
packages/lib/services/e2ee/utils.js
|
||||||
|
|
|
@ -1,209 +0,0 @@
|
||||||
diff --git a/android/build.gradle b/android/build.gradle
|
|
||||||
index 6afcbbf0cc8ca2d69dd78077d61e59a90b2136bb..9f8d72b4ec5b2b3d290975d6a255917c95300854 100644
|
|
||||||
--- a/android/build.gradle
|
|
||||||
+++ b/android/build.gradle
|
|
||||||
@@ -67,19 +67,19 @@ repositories {
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate UUIDs for each models contained in android/src/main/assets/
|
|
||||||
-tasks.register('genUUID') {
|
|
||||||
- doLast {
|
|
||||||
- fileTree(dir: "$rootDir/app/src/main/assets", exclude: ['*/*']).visit { fileDetails ->
|
|
||||||
- if (fileDetails.directory) {
|
|
||||||
- def odir = file("$rootDir/app/src/main/assets/$fileDetails.relativePath")
|
|
||||||
- def ofile = file("$odir/uuid")
|
|
||||||
- mkdir odir
|
|
||||||
- ofile.text = UUID.randomUUID().toString()
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
-}
|
|
||||||
-preBuild.dependsOn genUUID
|
|
||||||
+// tasks.register('genUUID') {
|
|
||||||
+// doLast {
|
|
||||||
+// fileTree(dir: "$rootDir/app/src/main/assets", exclude: ['*/*']).visit { fileDetails ->
|
|
||||||
+// if (fileDetails.directory) {
|
|
||||||
+// def odir = file("$rootDir/app/src/main/assets/$fileDetails.relativePath")
|
|
||||||
+// def ofile = file("$odir/uuid")
|
|
||||||
+// mkdir odir
|
|
||||||
+// ofile.text = UUID.randomUUID().toString()
|
|
||||||
+// }
|
|
||||||
+// }
|
|
||||||
+// }
|
|
||||||
+// }
|
|
||||||
+// preBuild.dependsOn genUUID
|
|
||||||
|
|
||||||
def kotlin_version = getExtOrDefault('kotlinVersion')
|
|
||||||
|
|
||||||
diff --git a/android/src/main/java/com/reactnativevosk/VoskModule.kt b/android/src/main/java/com/reactnativevosk/VoskModule.kt
|
|
||||||
index 0e2b6595b1b2cf1ee01c6c64239c4b0ea37fce19..5a8539b9cce8951967640dba755e29a4e3ff404a 100644
|
|
||||||
--- a/android/src/main/java/com/reactnativevosk/VoskModule.kt
|
|
||||||
+++ b/android/src/main/java/com/reactnativevosk/VoskModule.kt
|
|
||||||
@@ -19,13 +19,25 @@ class VoskModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
|
|
||||||
return "Vosk"
|
|
||||||
}
|
|
||||||
|
|
||||||
+ @ReactMethod
|
|
||||||
+ fun addListener(type: String?) {
|
|
||||||
+ // Keep: Required for RN built in Event Emitter Calls.
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ @ReactMethod
|
|
||||||
+ fun removeListeners(type: Int?) {
|
|
||||||
+ // Keep: Required for RN built in Event Emitter Calls.
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
override fun onResult(hypothesis: String) {
|
|
||||||
// Get text data from string object
|
|
||||||
val text = getHypothesisText(hypothesis)
|
|
||||||
|
|
||||||
// Stop recording if data found
|
|
||||||
if (text != null && text.isNotEmpty()) {
|
|
||||||
- cleanRecognizer();
|
|
||||||
+ // Don't auto-stop the recogniser - we want to do that when the user
|
|
||||||
+ // presses on "stop" only.
|
|
||||||
+ // cleanRecognizer();
|
|
||||||
sendEvent("onResult", text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -93,12 +105,11 @@ class VoskModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
|
|
||||||
@ReactMethod
|
|
||||||
fun loadModel(path: String, promise: Promise) {
|
|
||||||
cleanModel();
|
|
||||||
- StorageService.unpack(context, path, "models",
|
|
||||||
- { model: Model? ->
|
|
||||||
- this.model = model
|
|
||||||
- promise.resolve("Model successfully loaded")
|
|
||||||
- }
|
|
||||||
- ) { e: IOException ->
|
|
||||||
+
|
|
||||||
+ try {
|
|
||||||
+ this.model = Model(path);
|
|
||||||
+ promise.resolve("Model successfully loaded")
|
|
||||||
+ } catch (e: IOException) {
|
|
||||||
this.model = null
|
|
||||||
promise.reject(e)
|
|
||||||
}
|
|
||||||
@@ -153,6 +164,25 @@ class VoskModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
|
|
||||||
cleanRecognizer();
|
|
||||||
}
|
|
||||||
|
|
||||||
+ @ReactMethod
|
|
||||||
+ fun stopOnly() {
|
|
||||||
+ if (speechService != null) {
|
|
||||||
+ speechService!!.stop()
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ @ReactMethod
|
|
||||||
+ fun cleanup() {
|
|
||||||
+ if (speechService != null) {
|
|
||||||
+ speechService!!.shutdown();
|
|
||||||
+ speechService = null
|
|
||||||
+ }
|
|
||||||
+ if (recognizer != null) {
|
|
||||||
+ recognizer!!.close();
|
|
||||||
+ recognizer = null;
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
@ReactMethod
|
|
||||||
fun unload() {
|
|
||||||
cleanRecognizer();
|
|
||||||
diff --git a/lib/typescript/index.d.ts b/lib/typescript/index.d.ts
|
|
||||||
index 441e41cc402cca3a60b34978ef4fea976076259c..a173acebb4b314402550442ad471e0f7c706e3c4 100644
|
|
||||||
--- a/lib/typescript/index.d.ts
|
|
||||||
+++ b/lib/typescript/index.d.ts
|
|
||||||
@@ -10,6 +10,8 @@ export default class Vosk {
|
|
||||||
currentRegisteredEvents: EmitterSubscription[];
|
|
||||||
start: (grammar?: string[] | null) => Promise<String>;
|
|
||||||
stop: () => void;
|
|
||||||
+ stopOnly: () => void;
|
|
||||||
+ cleanup: () => void;
|
|
||||||
unload: () => void;
|
|
||||||
onResult: (onResult: (e: VoskEvent) => void) => EventSubscription;
|
|
||||||
onFinalResult: (onFinalResult: (e: VoskEvent) => void) => EventSubscription;
|
|
||||||
diff --git a/package.json b/package.json
|
|
||||||
index 707eddb8d68007f93071ac659c5b087c935c5f01..90ebe20f224eeec472c377df1fef9b15f2ff8200 100644
|
|
||||||
--- a/package.json
|
|
||||||
+++ b/package.json
|
|
||||||
@@ -11,12 +11,9 @@
|
|
||||||
"src",
|
|
||||||
"lib",
|
|
||||||
"android",
|
|
||||||
- "ios",
|
|
||||||
"cpp",
|
|
||||||
- "react-native-vosk.podspec",
|
|
||||||
"!lib/typescript/example",
|
|
||||||
"!android/build",
|
|
||||||
- "!ios/build",
|
|
||||||
"!**/__tests__",
|
|
||||||
"!**/__fixtures__",
|
|
||||||
"!**/__mocks__"
|
|
||||||
diff --git a/react-native-vosk.podspec b/react-native-vosk.podspec
|
|
||||||
deleted file mode 100644
|
|
||||||
index e3d41b90c5eef890c7a5108aaf16ac07d34a698b..0000000000000000000000000000000000000000
|
|
||||||
--- a/react-native-vosk.podspec
|
|
||||||
+++ /dev/null
|
|
||||||
@@ -1,41 +0,0 @@
|
|
||||||
-require "json"
|
|
||||||
-
|
|
||||||
-package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
||||||
-folly_version = '2021.06.28.00-v2'
|
|
||||||
-folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32'
|
|
||||||
-
|
|
||||||
-Pod::Spec.new do |s|
|
|
||||||
- s.name = "react-native-vosk"
|
|
||||||
- s.version = package["version"]
|
|
||||||
- s.summary = package["description"]
|
|
||||||
- s.homepage = package["homepage"]
|
|
||||||
- s.license = package["license"]
|
|
||||||
- s.authors = package["author"]
|
|
||||||
-
|
|
||||||
- s.platforms = { :ios => "10.0" }
|
|
||||||
- s.source = { :git => "https://github.com/riderodd/react-native-vosk.git", :tag => "#{s.version}" }
|
|
||||||
-
|
|
||||||
- s.source_files = "ios/**/*.{h,m,mm,swift}"
|
|
||||||
- s.resource_bundles = { 'Vosk' => ['ios/Vosk/*'] }
|
|
||||||
-
|
|
||||||
- s.dependency "React-Core"
|
|
||||||
- s.frameworks = "Accelerate"
|
|
||||||
- s.library = "c++"
|
|
||||||
- s.vendored_frameworks = "ios/libvosk.xcframework"
|
|
||||||
- s.requires_arc = true
|
|
||||||
-
|
|
||||||
- # Don't install the dependencies when we run `pod install` in the old architecture.
|
|
||||||
- if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then
|
|
||||||
- s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1"
|
|
||||||
- s.pod_target_xcconfig = {
|
|
||||||
- "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"",
|
|
||||||
- "CLANG_CXX_LANGUAGE_STANDARD" => "c++17"
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- s.dependency "React-Codegen"
|
|
||||||
- s.dependency "RCT-Folly", folly_version
|
|
||||||
- s.dependency "RCTRequired"
|
|
||||||
- s.dependency "RCTTypeSafety"
|
|
||||||
- s.dependency "ReactCommon/turbomodule/core"
|
|
||||||
- end
|
|
||||||
-end
|
|
||||||
diff --git a/src/index.tsx b/src/index.tsx
|
|
||||||
index d9f90c921d89b1b4d85e145443ed3376546a368a..29e4068dbd7500828a73145bd25497a52c9bf638 100644
|
|
||||||
--- a/src/index.tsx
|
|
||||||
+++ b/src/index.tsx
|
|
||||||
@@ -69,6 +69,15 @@ export default class Vosk {
|
|
||||||
VoskModule.stop();
|
|
||||||
};
|
|
||||||
|
|
||||||
+ stopOnly = () => {
|
|
||||||
+ VoskModule.stopOnly();
|
|
||||||
+ };
|
|
||||||
+
|
|
||||||
+ cleanup = () => {
|
|
||||||
+ this.cleanListeners();
|
|
||||||
+ VoskModule.cleanup();
|
|
||||||
+ };
|
|
||||||
+
|
|
||||||
unload = () => {
|
|
||||||
this.cleanListeners();
|
|
||||||
VoskModule.unload();
|
|
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
After Width: | Height: | Size: 378 KiB |
Binary file not shown.
After Width: | Height: | Size: 295 KiB |
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
|
@ -63,11 +63,22 @@ FROM node:18-slim
|
||||||
ARG user=joplin
|
ARG user=joplin
|
||||||
RUN useradd --create-home --shell /bin/bash $user
|
RUN useradd --create-home --shell /bin/bash $user
|
||||||
|
|
||||||
|
# Install PM2 and set home directory. Setting the PM2 data dir so modules/config persist regardless
|
||||||
|
# of user home.
|
||||||
|
RUN npm i -g pm2@5.4.3 && mkdir -p /opt/pm2 && chown -R $user:$user /opt/pm2
|
||||||
|
ENV PM2_HOME=/opt/pm2
|
||||||
|
|
||||||
USER $user
|
USER $user
|
||||||
|
|
||||||
COPY --chown=$user:$user --from=builder /build/packages /home/$user/packages
|
COPY --chown=$user:$user --from=builder /build/packages /home/$user/packages
|
||||||
COPY --chown=$user:$user --from=builder /usr/bin/tini /usr/local/bin/tini
|
COPY --chown=$user:$user --from=builder /usr/bin/tini /usr/local/bin/tini
|
||||||
|
|
||||||
|
# Install pm2-logrotate and default settings as the runtime user
|
||||||
|
RUN pm2 install pm2-logrotate \
|
||||||
|
&& pm2 set pm2-logrotate:max_size 100MB \
|
||||||
|
&& pm2 set pm2-logrotate:retain 5 \
|
||||||
|
&& pm2 set pm2-logrotate:compress true
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV RUNNING_IN_DOCKER=1
|
ENV RUNNING_IN_DOCKER=1
|
||||||
EXPOSE ${APP_PORT}
|
EXPOSE ${APP_PORT}
|
||||||
|
|
|
@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||||
# Sponsors
|
# Sponsors
|
||||||
|
|
||||||
<!-- SPONSORS-ORG -->
|
<!-- 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://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://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://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://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> <a href="https://damangameplay.in"><img title="Daman Game" width="256" src="https://joplinapp.org/images/sponsors/DamanGame.png" alt="Daman Game"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></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://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://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://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://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> <a href="https://damangameplay.in"><img title="Daman Game" width="256" src="https://joplinapp.org/images/sponsors/DamanGame.png" alt="Daman Game"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://domyessay.com"><img title="Essay writers DoMyEssay are dedicated to providing top-notch, custom-written papers that meet your academic requirements" width="256" src="https://joplinapp.org/images/sponsors/DoMyEssay.png" alt="DoMyEssay"/></a> <a href="https://essaypro.com/"><img title="best essay writing service" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="best essay writing service"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a> <a href="https://uk.notgamstop.com/bonuses/free-spins-no-deposit-no-gamstop/"><img title="free spins no deposit at NotGamstop" width="256" src="https://joplinapp.org/images/sponsors/NotGamStop.jpg" alt="free spins no deposit at NotGamstop"/></a>
|
||||||
<!-- SPONSORS-ORG -->
|
<!-- SPONSORS-ORG -->
|
||||||
|
|
||||||
* * *
|
* * *
|
||||||
|
|
|
@ -101,7 +101,6 @@
|
||||||
"packageManager": "yarn@4.9.2",
|
"packageManager": "yarn@4.9.2",
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
||||||
"react-native-vosk@0.1.12": "patch:react-native-vosk@npm%3A0.1.12#./.yarn/patches/react-native-vosk-npm-0.1.12-76b1caaae8.patch",
|
|
||||||
"eslint": "patch:eslint@8.57.1#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
"eslint": "patch:eslint@8.57.1#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||||
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
||||||
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
|
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
|
||||||
|
|
|
@ -434,6 +434,7 @@ class Application extends BaseApplication {
|
||||||
}
|
}
|
||||||
|
|
||||||
await Setting.saveAll();
|
await Setting.saveAll();
|
||||||
|
await this.database_.close();
|
||||||
|
|
||||||
// Need to call exit() explicitly, otherwise Node wait for any timeout to complete
|
// Need to call exit() explicitly, otherwise Node wait for any timeout to complete
|
||||||
// https://stackoverflow.com/questions/18050095
|
// https://stackoverflow.com/questions/18050095
|
||||||
|
|
|
@ -2,33 +2,44 @@
|
||||||
|
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
const fs = require('fs-extra');
|
import * as fs from 'fs-extra';
|
||||||
const Logger = require('@joplin/utils/Logger').default;
|
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||||
const { dirname } = require('@joplin/lib/path-utils');
|
import { dirname } from '@joplin/lib/path-utils';
|
||||||
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
|
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
|
||||||
const JoplinDatabase = require('@joplin/lib/JoplinDatabase').default;
|
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
|
||||||
const BaseModel = require('@joplin/lib/BaseModel').default;
|
import BaseModel from '@joplin/lib/BaseModel';
|
||||||
const Folder = require('@joplin/lib/models/Folder').default;
|
import Folder from '@joplin/lib/models/Folder';
|
||||||
const Note = require('@joplin/lib/models/Note').default;
|
import Note from '@joplin/lib/models/Note';
|
||||||
const Setting = require('@joplin/lib/models/Setting').default;
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
const exec = require('child_process').exec;
|
const exec = require('child_process').exec;
|
||||||
|
const nodeSqlite = require('sqlite3');
|
||||||
|
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
|
||||||
|
const { default: shimInitCli } = require('./utils/shimInitCli');
|
||||||
|
|
||||||
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
|
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
|
||||||
const joplinAppPath = `${__dirname}/main.js`;
|
const joplinAppPath = `${__dirname}/main.js`;
|
||||||
|
|
||||||
|
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null });
|
||||||
|
require('@joplin/lib/testing/test-utils');
|
||||||
|
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
logger.addTarget('console');
|
logger.addTarget(TargetType.Console);
|
||||||
logger.setLevel(Logger.LEVEL_ERROR);
|
logger.setLevel(Logger.LEVEL_ERROR);
|
||||||
|
|
||||||
const dbLogger = new Logger();
|
const dbLogger = new Logger();
|
||||||
dbLogger.addTarget('console');
|
dbLogger.addTarget(TargetType.Console);
|
||||||
dbLogger.setLevel(Logger.LEVEL_INFO);
|
dbLogger.setLevel(Logger.LEVEL_INFO);
|
||||||
|
|
||||||
const db = new JoplinDatabase(new DatabaseDriverNode());
|
const db = new JoplinDatabase(new DatabaseDriverNode());
|
||||||
db.setLogger(dbLogger);
|
db.setLogger(dbLogger);
|
||||||
|
|
||||||
function createClient(id) {
|
interface Client {
|
||||||
|
id: number;
|
||||||
|
profileDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClient(id: number): Client {
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
profileDir: `${baseDir}/client${id}`,
|
profileDir: `${baseDir}/client${id}`,
|
||||||
|
@ -37,13 +48,13 @@ function createClient(id) {
|
||||||
|
|
||||||
const client = createClient(1);
|
const client = createClient(1);
|
||||||
|
|
||||||
function execCommand(client, command) {
|
function execCommand(client: Client, command: string) {
|
||||||
const exePath = `node ${joplinAppPath}`;
|
const exePath = `node ${joplinAppPath}`;
|
||||||
const cmd = `${exePath} --update-geolocation-disabled --env dev --profile ${client.profileDir} ${command}`;
|
const cmd = `${exePath} --update-geolocation-disabled --env dev --profile ${client.profileDir} ${command}`;
|
||||||
logger.info(`${client.id}: ${command}`);
|
logger.info(`${client.id}: ${command}`);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
exec(cmd, (error, stdout, stderr) => {
|
exec(cmd, (error: string, stdout: string, stderr: string) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
logger.error(stderr);
|
logger.error(stderr);
|
||||||
reject(error);
|
reject(error);
|
||||||
|
@ -54,17 +65,17 @@ function execCommand(client, command) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertTrue(v) {
|
function assertTrue(v: unknown) {
|
||||||
if (!v) throw new Error(sprintf('Expected "true", got "%s"."', v));
|
if (!v) throw new Error(sprintf('Expected "true", got "%s"."', v));
|
||||||
process.stdout.write('.');
|
process.stdout.write('.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertFalse(v) {
|
function assertFalse(v: unknown) {
|
||||||
if (v) throw new Error(sprintf('Expected "false", got "%s"."', v));
|
if (v) throw new Error(sprintf('Expected "false", got "%s"."', v));
|
||||||
process.stdout.write('.');
|
process.stdout.write('.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertEquals(expected, real) {
|
function assertEquals(expected: unknown, real: unknown) {
|
||||||
if (expected !== real) throw new Error(sprintf('Expecting "%s", got "%s"', expected, real));
|
if (expected !== real) throw new Error(sprintf('Expecting "%s", got "%s"', expected, real));
|
||||||
process.stdout.write('.');
|
process.stdout.write('.');
|
||||||
}
|
}
|
||||||
|
@ -73,7 +84,7 @@ async function clearDatabase() {
|
||||||
await db.transactionExecBatch(['DELETE FROM folders', 'DELETE FROM notes', 'DELETE FROM tags', 'DELETE FROM note_tags', 'DELETE FROM resources', 'DELETE FROM deleted_items']);
|
await db.transactionExecBatch(['DELETE FROM folders', 'DELETE FROM notes', 'DELETE FROM tags', 'DELETE FROM note_tags', 'DELETE FROM resources', 'DELETE FROM deleted_items']);
|
||||||
}
|
}
|
||||||
|
|
||||||
const testUnits = {};
|
const testUnits: Record<string, ()=> Promise<void>> = {};
|
||||||
|
|
||||||
testUnits.testFolders = async () => {
|
testUnits.testFolders = async () => {
|
||||||
await execCommand(client, 'mkbook nb1');
|
await execCommand(client, 'mkbook nb1');
|
||||||
|
@ -85,10 +96,16 @@ testUnits.testFolders = async () => {
|
||||||
await execCommand(client, 'mkbook nb1');
|
await execCommand(client, 'mkbook nb1');
|
||||||
|
|
||||||
folders = await Folder.all();
|
folders = await Folder.all();
|
||||||
assertEquals(1, folders.length);
|
assertEquals(2, folders.length);
|
||||||
assertEquals('nb1', folders[0].title);
|
assertEquals('nb1', folders[0].title);
|
||||||
|
assertEquals('nb1', folders[1].title);
|
||||||
|
|
||||||
await execCommand(client, 'rm -r -f nb1');
|
await execCommand(client, 'rmbook -p -f nb1');
|
||||||
|
|
||||||
|
folders = await Folder.all();
|
||||||
|
assertEquals(1, folders.length);
|
||||||
|
|
||||||
|
await execCommand(client, 'rmbook -p -f nb1');
|
||||||
|
|
||||||
folders = await Folder.all();
|
folders = await Folder.all();
|
||||||
assertEquals(0, folders.length);
|
assertEquals(0, folders.length);
|
||||||
|
@ -102,7 +119,7 @@ testUnits.testNotes = async () => {
|
||||||
assertEquals(1, notes.length);
|
assertEquals(1, notes.length);
|
||||||
assertEquals('n1', notes[0].title);
|
assertEquals('n1', notes[0].title);
|
||||||
|
|
||||||
await execCommand(client, 'rm -f n1');
|
await execCommand(client, 'rmnote -p -f n1');
|
||||||
notes = await Note.all();
|
notes = await Note.all();
|
||||||
assertEquals(0, notes.length);
|
assertEquals(0, notes.length);
|
||||||
|
|
||||||
|
@ -112,12 +129,19 @@ testUnits.testNotes = async () => {
|
||||||
notes = await Note.all();
|
notes = await Note.all();
|
||||||
assertEquals(2, notes.length);
|
assertEquals(2, notes.length);
|
||||||
|
|
||||||
await execCommand(client, 'rm -f \'blabla*\'');
|
// Should fail to delete a non-existent note
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
await execCommand(client, 'rmnote -f \'blabla*\'');
|
||||||
|
} catch (error) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
assertEquals(failed, true);
|
||||||
|
|
||||||
notes = await Note.all();
|
notes = await Note.all();
|
||||||
assertEquals(2, notes.length);
|
assertEquals(2, notes.length);
|
||||||
|
|
||||||
await execCommand(client, 'rm -f \'n*\'');
|
await execCommand(client, 'rmnote -f -p \'n*\'');
|
||||||
|
|
||||||
notes = await Note.all();
|
notes = await Note.all();
|
||||||
assertEquals(0, notes.length);
|
assertEquals(0, notes.length);
|
||||||
|
@ -140,10 +164,12 @@ testUnits.testCat = async () => {
|
||||||
|
|
||||||
testUnits.testConfig = async () => {
|
testUnits.testConfig = async () => {
|
||||||
await execCommand(client, 'config editor vim');
|
await execCommand(client, 'config editor vim');
|
||||||
|
await Setting.reset();
|
||||||
await Setting.load();
|
await Setting.load();
|
||||||
assertEquals('vim', Setting.value('editor'));
|
assertEquals('vim', Setting.value('editor'));
|
||||||
|
|
||||||
await execCommand(client, 'config editor subl');
|
await execCommand(client, 'config editor subl');
|
||||||
|
await Setting.reset();
|
||||||
await Setting.load();
|
await Setting.load();
|
||||||
assertEquals('subl', Setting.value('editor'));
|
assertEquals('subl', Setting.value('editor'));
|
||||||
|
|
||||||
|
@ -201,15 +227,47 @@ testUnits.testMv = async () => {
|
||||||
await execCommand(client, 'mknote note2');
|
await execCommand(client, 'mknote note2');
|
||||||
await execCommand(client, 'mknote note3');
|
await execCommand(client, 'mknote note3');
|
||||||
await execCommand(client, 'mknote blabla');
|
await execCommand(client, 'mknote blabla');
|
||||||
await execCommand(client, 'mv \'note*\' nb2');
|
|
||||||
|
|
||||||
notes1 = await Note.previews(f1.id);
|
notes1 = await Note.previews(f1.id);
|
||||||
notes2 = await Note.previews(f2.id);
|
notes2 = await Note.previews(f2.id);
|
||||||
|
|
||||||
|
assertEquals(4, notes1.length);
|
||||||
|
assertEquals(1, notes2.length);
|
||||||
|
|
||||||
|
await execCommand(client, 'mv \'note*\' nb2');
|
||||||
|
|
||||||
|
notes2 = await Note.previews(f2.id);
|
||||||
|
notes1 = await Note.previews(f1.id);
|
||||||
|
|
||||||
assertEquals(1, notes1.length);
|
assertEquals(1, notes1.length);
|
||||||
assertEquals(4, notes2.length);
|
assertEquals(4, notes2.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
testUnits.testUse = async () => {
|
||||||
|
await execCommand(client, 'mkbook nb1');
|
||||||
|
await execCommand(client, 'mkbook nb2');
|
||||||
|
await execCommand(client, 'mknote n1');
|
||||||
|
await execCommand(client, 'mknote n2');
|
||||||
|
|
||||||
|
const f1 = await Folder.loadByTitle('nb1');
|
||||||
|
const f2 = await Folder.loadByTitle('nb2');
|
||||||
|
let notes1 = await Note.previews(f1.id);
|
||||||
|
let notes2 = await Note.previews(f2.id);
|
||||||
|
|
||||||
|
assertEquals(0, notes1.length);
|
||||||
|
assertEquals(2, notes2.length);
|
||||||
|
|
||||||
|
await execCommand(client, 'use nb1');
|
||||||
|
await execCommand(client, 'mknote note2');
|
||||||
|
await execCommand(client, 'mknote note3');
|
||||||
|
|
||||||
|
notes1 = await Note.previews(f1.id);
|
||||||
|
notes2 = await Note.previews(f2.id);
|
||||||
|
|
||||||
|
assertEquals(2, notes1.length);
|
||||||
|
assertEquals(2, notes2.length);
|
||||||
|
};
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await fs.remove(baseDir);
|
await fs.remove(baseDir);
|
||||||
|
|
||||||
|
@ -217,7 +275,9 @@ async function main() {
|
||||||
|
|
||||||
await db.open({ name: `${client.profileDir}/database.sqlite` });
|
await db.open({ name: `${client.profileDir}/database.sqlite` });
|
||||||
BaseModel.setDb(db);
|
BaseModel.setDb(db);
|
||||||
await Setting.load();
|
Setting.setConstant('rootProfileDir', client.profileDir);
|
||||||
|
Setting.setConstant('profileDir', client.profileDir);
|
||||||
|
await loadKeychainServiceAndSettings([]);
|
||||||
|
|
||||||
let onlyThisTest = 'testMv';
|
let onlyThisTest = 'testMv';
|
||||||
onlyThisTest = '';
|
onlyThisTest = '';
|
||||||
|
@ -234,7 +294,7 @@ async function main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main(process.argv).catch(error => {
|
main().catch(error => {
|
||||||
console.info('');
|
console.info('');
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
});
|
});
|
|
@ -73,7 +73,7 @@
|
||||||
"@joplin/tools": "~3.4",
|
"@joplin/tools": "~3.4",
|
||||||
"@types/fs-extra": "11.0.4",
|
"@types/fs-extra": "11.0.4",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/node": "18.19.103",
|
"@types/node": "18.19.112",
|
||||||
"@types/proper-lockfile": "^4.1.2",
|
"@types/proper-lockfile": "^4.1.2",
|
||||||
"gulp": "4.0.2",
|
"gulp": "4.0.2",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
content
|
|
@ -0,0 +1 @@
|
||||||
|
content
|
|
@ -0,0 +1 @@
|
||||||
|
content
|
|
@ -0,0 +1,3 @@
|
||||||
|
1. File without extension and leading `./`: [file1](./file1). Gets imported, but filename is converted to extension, like `<internal_id>.file1`
|
||||||
|
2. File without extension: [file2](file2). Not imported at all.
|
||||||
|
3. File with extension: [file3](file3.text). Gets imported properly.
|
|
@ -86,8 +86,14 @@ export default class InteropServiceHelper {
|
||||||
// pdfs.
|
// pdfs.
|
||||||
// https://github.com/laurent22/joplin/issues/6254.
|
// https://github.com/laurent22/joplin/issues/6254.
|
||||||
await win.webContents.executeJavaScript('document.querySelectorAll(\'details\').forEach(el=>el.setAttribute(\'open\',\'\'))');
|
await win.webContents.executeJavaScript('document.querySelectorAll(\'details\').forEach(el=>el.setAttribute(\'open\',\'\'))');
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
const data = await win.webContents.printToPDF({
|
||||||
const data = await win.webContents.printToPDF(options as any);
|
...options,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partially refactored old code before rule was applied
|
||||||
|
pageSize: options.pageSize as any,
|
||||||
|
// Allows users to override the CSS page size.
|
||||||
|
// See https://github.com/laurent22/joplin/issues/13096
|
||||||
|
preferCSSPageSize: true,
|
||||||
|
});
|
||||||
resolve(data);
|
resolve(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
|
|
|
@ -63,6 +63,8 @@ import { refreshFolders } from '@joplin/lib/folders-screen-utils';
|
||||||
import initializeCommandService from './utils/initializeCommandService';
|
import initializeCommandService from './utils/initializeCommandService';
|
||||||
import OcrDriverBase from '@joplin/lib/services/ocr/OcrDriverBase';
|
import OcrDriverBase from '@joplin/lib/services/ocr/OcrDriverBase';
|
||||||
import PerformanceLogger from '@joplin/lib/PerformanceLogger';
|
import PerformanceLogger from '@joplin/lib/PerformanceLogger';
|
||||||
|
import Note from '@joplin/lib/models/Note';
|
||||||
|
import Resource from '@joplin/lib/models/Resource';
|
||||||
|
|
||||||
const perfLogger = PerformanceLogger.create();
|
const perfLogger = PerformanceLogger.create();
|
||||||
|
|
||||||
|
@ -683,6 +685,11 @@ class Application extends BaseApplication {
|
||||||
debug: new DebugService(reg.db()),
|
debug: new DebugService(reg.db()),
|
||||||
resourceService: ResourceService.instance(),
|
resourceService: ResourceService.instance(),
|
||||||
searchEngine: SearchEngine.instance(),
|
searchEngine: SearchEngine.instance(),
|
||||||
|
shim,
|
||||||
|
Note,
|
||||||
|
Folder,
|
||||||
|
Resource,
|
||||||
|
Setting,
|
||||||
ocrService: () => this.ocrService_,
|
ocrService: () => this.ocrService_,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { connect } from 'react-redux';
|
||||||
import { AppState } from '../../app.reducer';
|
import { AppState } from '../../app.reducer';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import CommandService from '@joplin/lib/services/CommandService';
|
import CommandService from '@joplin/lib/services/CommandService';
|
||||||
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk';
|
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk/ppk';
|
||||||
import ToggleAdvancedSettingsButton from '../ConfigScreen/controls/ToggleAdvancedSettingsButton';
|
import ToggleAdvancedSettingsButton from '../ConfigScreen/controls/ToggleAdvancedSettingsButton';
|
||||||
import MacOSMissingPasswordHelpLink from '../ConfigScreen/controls/MissingPasswordHelpLink';
|
import MacOSMissingPasswordHelpLink from '../ConfigScreen/controls/MissingPasswordHelpLink';
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,7 @@ import useEditorSearchHandler from '../utils/useEditorSearchHandler';
|
||||||
import CommandService from '@joplin/lib/services/CommandService';
|
import CommandService from '@joplin/lib/services/CommandService';
|
||||||
import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange';
|
import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange';
|
||||||
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
|
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
|
||||||
|
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
|
||||||
|
|
||||||
const logger = Logger.create('CodeMirror6');
|
const logger = Logger.create('CodeMirror6');
|
||||||
const logDebug = (message: string) => logger.debug(message);
|
const logDebug = (message: string) => logger.debug(message);
|
||||||
|
@ -272,6 +273,17 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||||
props.noteId, props.useCustomPdfViewer,
|
props.noteId, props.useCustomPdfViewer,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (event: ResourceChangeEvent) => {
|
||||||
|
editorRef.current?.onResourceChanged(event.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventManager.on(EventName.ResourceChange, listener);
|
||||||
|
return () => {
|
||||||
|
eventManager.off(EventName.ResourceChange, listener);
|
||||||
|
};
|
||||||
|
}, [props.resourceInfos]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!webviewReady) return;
|
if (!webviewReady) return;
|
||||||
|
|
||||||
|
|
|
@ -110,12 +110,12 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
|
||||||
|
|
||||||
const editor = createEditor(editorContainerRef.current, {
|
const editor = createEditor(editorContainerRef.current, {
|
||||||
...editorProps,
|
...editorProps,
|
||||||
resolveImageSrc: async src => {
|
resolveImageSrc: async (src, reloadCounter) => {
|
||||||
const url = parseResourceUrl(src);
|
const url = parseResourceUrl(src);
|
||||||
if (!url.itemId) return null;
|
if (!url.itemId) return null;
|
||||||
const item = await Resource.load(url.itemId);
|
const item = await Resource.load(url.itemId);
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
return `${getResourceBaseUrl()}/${resourceFilename(item)}`;
|
return `${getResourceBaseUrl()}/${resourceFilename(item)}${reloadCounter ? `?r=${reloadCounter}` : ''}`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
editor.addStyles({
|
editor.addStyles({
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { MarkupToHtmlOptions } from '../../hooks/useMarkupToHtml';
|
||||||
import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
|
import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
|
||||||
import { RefObject, SetStateAction } from 'react';
|
import { RefObject, SetStateAction } from 'react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
|
||||||
|
|
||||||
export interface AllAssetsOptions {
|
export interface AllAssetsOptions {
|
||||||
contentMaxWidthTarget?: string;
|
contentMaxWidthTarget?: string;
|
||||||
|
@ -214,10 +215,8 @@ export function defaultFormNote(): FormNote {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceInfo {
|
export interface ResourceInfo {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
localState: ResourceLocalStateEntity;
|
||||||
localState: any;
|
item: ResourceEntity;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
||||||
item: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceInfos {
|
export interface ResourceInfos {
|
||||||
|
|
|
@ -251,8 +251,6 @@ export default class PromptDialog extends React.Component<Props, any> {
|
||||||
} else {
|
} else {
|
||||||
onClose(true);
|
onClose(true);
|
||||||
}
|
}
|
||||||
} else if (event.key === 'Escape') {
|
|
||||||
onClose(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -309,7 +307,7 @@ export default class PromptDialog extends React.Component<Props, any> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog className='prompt-dialog' contentStyle={styles.dialog}>
|
<Dialog className='prompt-dialog' contentStyle={styles.dialog} onCancel={() => onClose(false, 'cancel')}>
|
||||||
<label style={styles.label}>{this.props.label ? this.props.label : ''}</label>
|
<label style={styles.label}>{this.props.label ? this.props.label : ''}</label>
|
||||||
<div style={{ display: 'inline-block', color: 'black', backgroundColor: theme.backgroundColor }}>
|
<div style={{ display: 'inline-block', color: 'black', backgroundColor: theme.backgroundColor }}>
|
||||||
{inputComp}
|
{inputComp}
|
||||||
|
|
|
@ -72,4 +72,10 @@ export default class MainScreen {
|
||||||
await setFilePickerResponse(electronApp, [path]);
|
await setFilePickerResponse(electronApp, [path]);
|
||||||
await activateMainMenuItem(electronApp, 'HTML - HTML document (Directory)', 'Import');
|
await activateMainMenuItem(electronApp, 'HTML - HTML document (Directory)', 'Import');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async pluginPanelLocator(pluginId: string) {
|
||||||
|
return this.page.locator(
|
||||||
|
`iframe[id^=${JSON.stringify(`plugin-view-${pluginId}`)}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,6 +45,41 @@ test.describe('pluginApi', () => {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should report the correct visibility state for dialogs', async ({ startAppWithPlugins }) => {
|
||||||
|
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/dialogs.js']);
|
||||||
|
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||||
|
await mainScreen.createNewNote('Dialog test note');
|
||||||
|
|
||||||
|
const editor = mainScreen.noteEditor;
|
||||||
|
const expectVisible = async (visible: boolean) => {
|
||||||
|
// Check UI visibility
|
||||||
|
if (visible) {
|
||||||
|
await expect(mainScreen.dialog).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(mainScreen.dialog).not.toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check visibility reported through the plugin API
|
||||||
|
await expect.poll(async () => {
|
||||||
|
await mainScreen.goToAnything.runCommand(app, 'getTestDialogVisibility');
|
||||||
|
|
||||||
|
const editorContent = await editor.contentLocator();
|
||||||
|
return editorContent.textContent();
|
||||||
|
}).toBe(JSON.stringify({
|
||||||
|
visible: visible,
|
||||||
|
active: visible,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
await expectVisible(false);
|
||||||
|
|
||||||
|
await mainScreen.goToAnything.runCommand(app, 'showTestDialog');
|
||||||
|
await expectVisible(true);
|
||||||
|
|
||||||
|
// Submitting the dialog should include form data in the output
|
||||||
|
await mainScreen.dialog.getByRole('button', { name: 'Okay' }).click();
|
||||||
|
await expectVisible(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('should be possible to create multiple toasts with the same text from a plugin', async ({ startAppWithPlugins }) => {
|
test('should be possible to create multiple toasts with the same text from a plugin', async ({ startAppWithPlugins }) => {
|
||||||
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/showToast.js']);
|
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/showToast.js']);
|
||||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||||
|
@ -122,5 +157,30 @@ test.describe('pluginApi', () => {
|
||||||
await msleep(Second);
|
await msleep(Second);
|
||||||
await expect(noteEditor.codeMirrorEditor).toHaveText(expectedUpdatedText);
|
await expect(noteEditor.codeMirrorEditor).toHaveText(expectedUpdatedText);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should support hiding and showing panels', async ({ startAppWithPlugins }) => {
|
||||||
|
const { mainWindow, app } = await startAppWithPlugins(['resources/test-plugins/panels.js']);
|
||||||
|
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||||
|
await mainScreen.createNewNote('Test note (panels)');
|
||||||
|
|
||||||
|
const panelLocator = await mainScreen.pluginPanelLocator('org.joplinapp.plugins.example.panels');
|
||||||
|
|
||||||
|
const noteEditor = mainScreen.noteEditor;
|
||||||
|
await mainScreen.goToAnything.runCommand(app, 'testShowPanel');
|
||||||
|
await expect(noteEditor.codeMirrorEditor).toHaveText('visible');
|
||||||
|
|
||||||
|
// Panel should be visible
|
||||||
|
await expect(panelLocator).toBeVisible();
|
||||||
|
// The panel should have the expected content
|
||||||
|
const panelContent = panelLocator.contentFrame();
|
||||||
|
await expect(
|
||||||
|
panelContent.getByRole('heading', { name: 'Panel content' }),
|
||||||
|
).toBeAttached();
|
||||||
|
|
||||||
|
await mainScreen.goToAnything.runCommand(app, 'testHidePanel');
|
||||||
|
await expect(noteEditor.codeMirrorEditor).toHaveText('hidden');
|
||||||
|
|
||||||
|
await expect(panelLocator).not.toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -47,5 +47,22 @@ joplin.plugins.register({
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await joplin.commands.register({
|
||||||
|
name: 'getTestDialogVisibility',
|
||||||
|
label: 'Returns the dialog visibility state',
|
||||||
|
execute: async () => {
|
||||||
|
// panels.visible should also work for dialogs.
|
||||||
|
const visible = await joplin.views.panels.visible(dialogHandle);
|
||||||
|
// For dialogs, isActive should return the visibility.
|
||||||
|
// (Prefer panels.visible for dialogs).
|
||||||
|
const active = await joplin.views.panels.isActive(dialogHandle);
|
||||||
|
|
||||||
|
await joplin.commands.execute('editor.setText', JSON.stringify({
|
||||||
|
visible,
|
||||||
|
active,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
// Allows referencing the Joplin global:
|
||||||
|
/* eslint-disable no-undef */
|
||||||
|
|
||||||
|
// Allows the `joplin-manifest` block comment:
|
||||||
|
/* eslint-disable multiline-comment-style */
|
||||||
|
|
||||||
|
/* joplin-manifest:
|
||||||
|
{
|
||||||
|
"id": "org.joplinapp.plugins.example.panels",
|
||||||
|
"manifest_version": 1,
|
||||||
|
"app_min_version": "3.1",
|
||||||
|
"name": "JS Bundle test",
|
||||||
|
"description": "JS Bundle Test plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "",
|
||||||
|
"homepage_url": "https://joplinapp.org"
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
const waitFor = async (condition) => {
|
||||||
|
const wait = () => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(() => resolve(), 100);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (await condition()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause for a brief delay
|
||||||
|
await wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Condition was never true');
|
||||||
|
};
|
||||||
|
|
||||||
|
joplin.plugins.register({
|
||||||
|
onStart: async function() {
|
||||||
|
const panels = joplin.views.panels;
|
||||||
|
const view = await panels.create('panelTestView');
|
||||||
|
await panels.setHtml(view, '<h1>Panel content</h1><p>Test</p>');
|
||||||
|
await panels.hide(view);
|
||||||
|
|
||||||
|
|
||||||
|
await joplin.commands.register({
|
||||||
|
name: 'testShowPanel',
|
||||||
|
label: 'Test panel visibility',
|
||||||
|
execute: async () => {
|
||||||
|
await panels.show(view);
|
||||||
|
await waitFor(async () => {
|
||||||
|
return await panels.visible(view);
|
||||||
|
});
|
||||||
|
await joplin.commands.execute('editor.setText', 'visible');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await joplin.commands.register({
|
||||||
|
name: 'testHidePanel',
|
||||||
|
label: 'Test: Hide the panel',
|
||||||
|
execute: async () => {
|
||||||
|
await panels.hide(view);
|
||||||
|
await waitFor(async () => {
|
||||||
|
return !await panels.visible(view);
|
||||||
|
});
|
||||||
|
|
||||||
|
await joplin.commands.execute('editor.setText', 'hidden');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
|
@ -131,7 +131,7 @@
|
||||||
"homepage": "https://github.com/laurent22/joplin#readme",
|
"homepage": "https://github.com/laurent22/joplin#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "5.2.0",
|
"7zip-bin": "5.2.0",
|
||||||
"@axe-core/playwright": "4.10.1",
|
"@axe-core/playwright": "4.10.2",
|
||||||
"@electron/notarize": "2.5.0",
|
"@electron/notarize": "2.5.0",
|
||||||
"@electron/rebuild": "3.7.2",
|
"@electron/rebuild": "3.7.2",
|
||||||
"@fortawesome/fontawesome-free": "5.15.4",
|
"@fortawesome/fontawesome-free": "5.15.4",
|
||||||
|
@ -147,7 +147,7 @@
|
||||||
"@testing-library/react-hooks": "8.0.1",
|
"@testing-library/react-hooks": "8.0.1",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/mustache": "4.2.6",
|
"@types/mustache": "4.2.6",
|
||||||
"@types/node": "18.19.103",
|
"@types/node": "18.19.112",
|
||||||
"@types/react": "18.3.23",
|
"@types/react": "18.3.23",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "18.3.7",
|
||||||
"@types/react-redux": "7.1.33",
|
"@types/react-redux": "7.1.33",
|
||||||
|
@ -160,7 +160,7 @@
|
||||||
"compare-versions": "6.1.1",
|
"compare-versions": "6.1.1",
|
||||||
"countable": "3.0.1",
|
"countable": "3.0.1",
|
||||||
"debounce": "1.2.1",
|
"debounce": "1.2.1",
|
||||||
"electron": "35.7.5",
|
"electron": "37.4.0",
|
||||||
"electron-builder": "24.13.3",
|
"electron-builder": "24.13.3",
|
||||||
"electron-updater": "6.6.2",
|
"electron-updater": "6.6.2",
|
||||||
"electron-window-state": "5.0.3",
|
"electron-window-state": "5.0.3",
|
||||||
|
|
|
@ -89,8 +89,8 @@ android {
|
||||||
applicationId "net.cozic.joplin"
|
applicationId "net.cozic.joplin"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 2097779
|
versionCode 2097780
|
||||||
versionName "3.4.6"
|
versionName "3.4.7"
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||||
}
|
}
|
||||||
|
@ -100,6 +100,8 @@ android {
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
cppFlags '-DCMAKE_BUILD_TYPE=Release'
|
cppFlags '-DCMAKE_BUILD_TYPE=Release'
|
||||||
|
// For 16 KB pages. This should be removable after upgrading to NDK r28
|
||||||
|
arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,8 +38,9 @@ add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||||
set(WHISPER_LIB_DIR ${CMAKE_SOURCE_DIR}/../../../../vendor/whisper.cpp)
|
set(WHISPER_LIB_DIR ${CMAKE_SOURCE_DIR}/../../../../vendor/whisper.cpp)
|
||||||
|
|
||||||
# Based on the Whisper.cpp Android example:
|
# Based on the Whisper.cpp Android example:
|
||||||
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -O3 ")
|
set(SHARED_FLAGS "-O3 ")
|
||||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -fvisibility=hidden -fvisibility-inlines-hidden -ffunction-sections -fdata-sections")
|
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${SHARED_FLAGS} ")
|
||||||
|
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${SHARED_FLAGS} -fvisibility=hidden -fvisibility-inlines-hidden -ffunction-sections -fdata-sections")
|
||||||
|
|
||||||
# Whisper: See https://stackoverflow.com/a/76290722
|
# Whisper: See https://stackoverflow.com/a/76290722
|
||||||
add_subdirectory(${WHISPER_LIB_DIR} ./whisper)
|
add_subdirectory(${WHISPER_LIB_DIR} ./whisper)
|
||||||
|
|
|
@ -24,29 +24,8 @@ buildscript {
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
|
||||||
|
|
||||||
// Seems to be required for react-native-vosk, otherwise the lib looks for it at "https://maven.aliyun.com/repository/jcenter/com/alphacephei/vosk-android/0.3.46/vosk-android-0.3.46.aar" but it's not there. And we get this error:
|
|
||||||
//
|
|
||||||
// Execution failed for task ':app:checkDebugAarMetadata'.
|
|
||||||
// > Could not resolve all files for configuration ':app:debugRuntimeClasspath'.
|
|
||||||
// > Failed to transform vosk-android-0.3.46.aar (com.alphacephei:vosk-android:0.3.46) to match attributes {artifactType=android-aar-metadata, org.gradle.status=release}.
|
|
||||||
// > Could not find vosk-android-0.3.46.aar (com.alphacephei:vosk-android:0.3.46).
|
|
||||||
// Searched in the following locations:
|
|
||||||
// https://maven.aliyun.com/repository/jcenter/com/alphacephei/vosk-android/0.3.46/vosk-android-0.3.46.aar
|
|
||||||
//
|
|
||||||
// But according to this page, the lib is on the Apache repository:
|
|
||||||
//
|
|
||||||
// https://search.maven.org/artifact/com.alphacephei/vosk-android/0.3.46/aar
|
|
||||||
maven { url "https://maven.apache.org" }
|
|
||||||
|
|
||||||
// Also required for react-native-vosk?
|
|
||||||
maven { url "https://maven.google.com" }
|
|
||||||
|
|
||||||
// Maybe still needed to fetch above package?
|
|
||||||
|
|
||||||
google()
|
google()
|
||||||
maven { url 'https://www.jitpack.io' }
|
mavenCentral()
|
||||||
|
|
||||||
maven {
|
maven {
|
||||||
// expo-camera bundles a custom com.google.android:cameraview
|
// expo-camera bundles a custom com.google.android:cameraview
|
||||||
|
|
|
@ -66,12 +66,12 @@ describe('ComboBox', () => {
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('changing the search query should limit which items are visible', () => {
|
test('changing the search query should limit which items are visible and be case insensitive', () => {
|
||||||
const testItems = [
|
const testItems = [
|
||||||
{ title: 'a' },
|
{ title: 'a' },
|
||||||
{ title: 'b' },
|
{ title: 'b' },
|
||||||
{ title: 'c' },
|
{ title: 'c' },
|
||||||
{ title: 'aa' },
|
{ title: 'Aa' },
|
||||||
];
|
];
|
||||||
const { unmount } = render(
|
const { unmount } = render(
|
||||||
<WrappedComboBox items={testItems}/>,
|
<WrappedComboBox items={testItems}/>,
|
||||||
|
@ -82,7 +82,7 @@ describe('ComboBox', () => {
|
||||||
|
|
||||||
const updatedResults = getSearchResults();
|
const updatedResults = getSearchResults();
|
||||||
expect(updatedResults[0]).toHaveTextContent('a');
|
expect(updatedResults[0]).toHaveTextContent('a');
|
||||||
expect(updatedResults[1]).toHaveTextContent('aa');
|
expect(updatedResults[1]).toHaveTextContent('Aa');
|
||||||
expect(updatedResults).toHaveLength(2);
|
expect(updatedResults).toHaveLength(2);
|
||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
|
|
|
@ -12,7 +12,7 @@ import focusView from '../utils/focusView';
|
||||||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||||
import NestableFlatList, { NestableFlatListControl } from './NestableFlatList';
|
import NestableFlatList, { NestableFlatListControl } from './NestableFlatList';
|
||||||
import useKeyboardState from '../utils/hooks/useKeyboardState';
|
import useKeyboardState from '../utils/hooks/useKeyboardState';
|
||||||
const naturalCompare = require('string-natural-compare');
|
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
|
||||||
|
|
||||||
|
|
||||||
export interface Option {
|
export interface Option {
|
||||||
|
@ -64,17 +64,20 @@ interface UseSearchResultsOptions {
|
||||||
const useSearchResults = ({
|
const useSearchResults = ({
|
||||||
search, setSearch, options, onAddItem, canAddItem,
|
search, setSearch, options, onAddItem, canAddItem,
|
||||||
}: UseSearchResultsOptions) => {
|
}: UseSearchResultsOptions) => {
|
||||||
|
const collatorLocale = getCollatorLocale();
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
|
const collator = getCollator(collatorLocale);
|
||||||
|
const lowerSearch = search?.toLowerCase();
|
||||||
return options
|
return options
|
||||||
.filter(option => option.title.startsWith(search))
|
.filter(option => option.title.toLowerCase().includes(lowerSearch))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.title === b.title) return 0;
|
if (a.title === b.title) return 0;
|
||||||
// Full matches should go first
|
// Full matches should go first
|
||||||
if (a.title === search) return -1;
|
if (a.title.toLowerCase() === lowerSearch) return -1;
|
||||||
if (b.title === search) return 1;
|
if (b.title.toLowerCase() === lowerSearch) return 1;
|
||||||
return naturalCompare(a.title, b.title);
|
return collator.compare(a.title, b.title);
|
||||||
});
|
});
|
||||||
}, [search, options]);
|
}, [search, options, collatorLocale]);
|
||||||
|
|
||||||
const canAdd = (
|
const canAdd = (
|
||||||
!!onAddItem
|
!!onAddItem
|
||||||
|
@ -254,6 +257,8 @@ const SearchResult: React.FC<SearchResultProps> = ({
|
||||||
<View style={[styles.optionContent, selected && styles.optionContentSelected]}>
|
<View style={[styles.optionContent, selected && styles.optionContentSelected]}>
|
||||||
{icon}
|
{icon}
|
||||||
<Text
|
<Text
|
||||||
|
ellipsizeMode='tail'
|
||||||
|
numberOfLines={1}
|
||||||
style={styles.optionLabel}
|
style={styles.optionLabel}
|
||||||
>{text}</Text>
|
>{text}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
@ -458,10 +463,11 @@ const useInputEventHandlers = ({
|
||||||
} else if (key === 'ArrowUp') {
|
} else if (key === 'ArrowUp') {
|
||||||
selectedIndexControl.onPreviousResult();
|
selectedIndexControl.onPreviousResult();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (key === 'Enter') {
|
} else if (key === 'Enter' && Platform.OS === 'web') {
|
||||||
// This case is necessary on web to prevent the
|
// This case is necessary on web to prevent the
|
||||||
// search input from becoming defocused after
|
// search input from becoming defocused after
|
||||||
// pressing "enter".
|
// pressing "enter". Enter key behavior is handled
|
||||||
|
// elsewhere for other platforms.
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onSubmit();
|
onSubmit();
|
||||||
setSearch('');
|
setSearch('');
|
||||||
|
@ -584,6 +590,7 @@ const ComboBox: React.FC<Props> = ({
|
||||||
onChangeText={setSearch}
|
onChangeText={setSearch}
|
||||||
onKeyPress={onKeyPress}
|
onKeyPress={onKeyPress}
|
||||||
onSubmitEditing={onSubmit}
|
onSubmitEditing={onSubmit}
|
||||||
|
submitBehavior='submit'
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-activedescendant={showSearchResults ? activeId : undefined}
|
aria-activedescendant={showSearchResults ? activeId : undefined}
|
||||||
aria-controls={`menuBox-${baseId}`}
|
aria-controls={`menuBox-${baseId}`}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View, LayoutRectangle, ViewStyle, TextStyle, FlatList } from 'react-native';
|
import { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View, LayoutRectangle, ViewStyle, TextStyle, FlatList, Platform } from 'react-native';
|
||||||
import { Component, ReactElement } from 'react';
|
import { Component, ReactElement } from 'react';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import { EdgeInsets, SafeAreaInsetsContext } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
type ValueType = string;
|
type ValueType = string;
|
||||||
export interface DropdownListItem {
|
export interface DropdownListItem {
|
||||||
|
@ -56,25 +57,43 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateHeaderCoordinates = () => {
|
private updateHeaderCoordinates = (insets: EdgeInsets) => {
|
||||||
if (!this.headerRef) return;
|
if (!this.headerRef) return;
|
||||||
|
|
||||||
// https://stackoverflow.com/questions/30096038/react-native-getting-the-position-of-an-element
|
// https://stackoverflow.com/questions/30096038/react-native-getting-the-position-of-an-element
|
||||||
this.headerRef.measure((_fx, _fy, width, height, px, py) => {
|
this.headerRef.measure((_fx, _fy, width, height, px, py) => {
|
||||||
const lastLayout = this.state.headerSize;
|
const lastLayout = this.state.headerSize;
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
|
// The opening position of the dropdown must be offset to cater for insets, on newer versions of Android which use edge to edge by default
|
||||||
|
// If the dropdown fills the full height of the screen, the offset gets ignored and does not cause anything to be truncated
|
||||||
|
if (Platform.OS === 'android' && Platform.Version >= 35) {
|
||||||
|
const windowHeight = Dimensions.get('window').height;
|
||||||
|
const windowWidth = Dimensions.get('window').width;
|
||||||
|
const isLandscape = windowWidth > windowHeight;
|
||||||
|
|
||||||
|
if (isLandscape) {
|
||||||
|
offsetX = insets.left;
|
||||||
|
offsetY = insets.top;
|
||||||
|
} else {
|
||||||
|
offsetY = insets.top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (px !== lastLayout.x || py !== lastLayout.y || width !== lastLayout.width || height !== lastLayout.height) {
|
if (px !== lastLayout.x || py !== lastLayout.y || width !== lastLayout.width || height !== lastLayout.height) {
|
||||||
this.setState({
|
this.setState({
|
||||||
headerSize: { x: px, y: py, width: width, height: height },
|
headerSize: { x: px - offsetX, y: py - offsetY, width: width, height: height },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private onOpenList = () => {
|
private onOpenList = (insets: EdgeInsets) => {
|
||||||
// On iOS, we need to re-measure just before opening the list. Measurements from just after
|
// On iOS, we need to re-measure just before opening the list. Measurements from just after
|
||||||
// onLayout can be inaccurate in some cases (in the past, this had caused the menu to be
|
// onLayout can be inaccurate in some cases (in the past, this had caused the menu to be
|
||||||
// drawn far offscreen).
|
// drawn far offscreen).
|
||||||
this.updateHeaderCoordinates();
|
this.updateHeaderCoordinates(insets);
|
||||||
this.setState({ listVisible: true });
|
this.setState({ listVisible: true });
|
||||||
};
|
};
|
||||||
private onCloseList = () => {
|
private onCloseList = () => {
|
||||||
|
@ -92,10 +111,16 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public render() {
|
private renderWithInsets(insets: EdgeInsets) {
|
||||||
|
let offsetHeight = 0;
|
||||||
|
|
||||||
|
if (Platform.OS === 'android' && Platform.Version >= 35) {
|
||||||
|
offsetHeight = insets.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
const items = this.props.items;
|
const items = this.props.items;
|
||||||
const itemHeight = 60;
|
const itemHeight = 60;
|
||||||
const windowHeight = Dimensions.get('window').height - 50;
|
const windowHeight = Dimensions.get('window').height - 50 - offsetHeight;
|
||||||
const windowWidth = Dimensions.get('window').width;
|
const windowWidth = Dimensions.get('window').width;
|
||||||
|
|
||||||
// Dimensions doesn't return quite the right dimensions so leave an extra gap to make
|
// Dimensions doesn't return quite the right dimensions so leave an extra gap to make
|
||||||
|
@ -205,13 +230,13 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
|
||||||
<View style={{ flex: 1, flexDirection: 'column' }}>
|
<View style={{ flex: 1, flexDirection: 'column' }}>
|
||||||
<View
|
<View
|
||||||
style={{ flexDirection: 'row', flex: 1, alignItems: 'center' }}
|
style={{ flexDirection: 'row', flex: 1, alignItems: 'center' }}
|
||||||
onLayout={this.updateHeaderCoordinates}
|
onLayout={() => this.updateHeaderCoordinates(insets)}
|
||||||
ref={ref => { this.headerRef = ref; } }
|
ref={ref => { this.headerRef = ref; } }
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={headerWrapperStyle}
|
style={headerWrapperStyle}
|
||||||
disabled={this.props.disabled}
|
disabled={this.props.disabled}
|
||||||
onPress={this.onOpenList}
|
onPress={() => this.onOpenList(insets)}
|
||||||
accessibilityRole='button'
|
accessibilityRole='button'
|
||||||
accessibilityHint={[this.props.accessibilityHint, _('Opens dropdown')].join(' ')}
|
accessibilityHint={[this.props.accessibilityHint, _('Opens dropdown')].join(' ')}
|
||||||
>
|
>
|
||||||
|
@ -268,6 +293,14 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
return (
|
||||||
|
<SafeAreaInsetsContext.Consumer>
|
||||||
|
{(insets) => this.renderWithInsets(insets)}
|
||||||
|
</SafeAreaInsetsContext.Consumer>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Dropdown;
|
export default Dropdown;
|
||||||
|
|
|
@ -0,0 +1,132 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Store } from 'redux';
|
||||||
|
import { AppState } from '../utils/types';
|
||||||
|
import TestProviderStack from './testing/TestProviderStack';
|
||||||
|
import { switchClient, setupDatabase, mockMobilePlatform, mockFetch, waitFor } from '@joplin/lib/testing/test-utils';
|
||||||
|
import createMockReduxStore from '../utils/testing/createMockReduxStore';
|
||||||
|
import setupGlobalStore from '../utils/testing/setupGlobalStore';
|
||||||
|
import { act, fireEvent, render, screen } from '@testing-library/react-native';
|
||||||
|
import FeedbackBanner from './FeedbackBanner';
|
||||||
|
|
||||||
|
interface WrapperProps { }
|
||||||
|
|
||||||
|
let store: Store<AppState>;
|
||||||
|
const WrappedFeedbackBanner: React.FC<WrapperProps> = () => {
|
||||||
|
return <TestProviderStack store={store}>
|
||||||
|
<FeedbackBanner/>
|
||||||
|
</TestProviderStack>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFeedbackButton = (positive: boolean) => {
|
||||||
|
return screen.getByRole('button', { name: positive ? 'Useful' : 'Not useful' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSurveyLink = () => {
|
||||||
|
return screen.getByRole('button', { name: 'Take survey' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFeedbackServer = (surveyName = 'web-app-test') => {
|
||||||
|
let helpfulCount = 0;
|
||||||
|
let unhelpfulCount = 0;
|
||||||
|
|
||||||
|
const { reset } = mockFetch((request) => {
|
||||||
|
const surveyBaseUrls = [
|
||||||
|
'https://objects.joplinusercontent.com/',
|
||||||
|
'http://localhost:3430/',
|
||||||
|
];
|
||||||
|
const isSurveyRequest = surveyBaseUrls.some(url => request.url.startsWith(url));
|
||||||
|
if (!isSurveyRequest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === `/r/survey--${surveyName}--helpful`) {
|
||||||
|
helpfulCount ++;
|
||||||
|
} else if (url.pathname === `/r/survey--${surveyName}--unhelpful`) {
|
||||||
|
unhelpfulCount ++;
|
||||||
|
} else {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The feedback server always redirects to another URL after a
|
||||||
|
// successful request. Mock this by always redirecting to the
|
||||||
|
// same URL.
|
||||||
|
return new Response('', {
|
||||||
|
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/302
|
||||||
|
status: 302,
|
||||||
|
statusText: 'Found',
|
||||||
|
headers: [
|
||||||
|
['location', 'https://joplinapp.org'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset,
|
||||||
|
get helpfulCount() {
|
||||||
|
return helpfulCount;
|
||||||
|
},
|
||||||
|
get unhelpfulCount() {
|
||||||
|
return unhelpfulCount;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('FeedbackBanner', () => {
|
||||||
|
const resetMobilePlatform = ()=>{};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await setupDatabase(0);
|
||||||
|
await switchClient(0);
|
||||||
|
|
||||||
|
store = createMockReduxStore();
|
||||||
|
setupGlobalStore(store);
|
||||||
|
|
||||||
|
jest.useFakeTimers({ advanceTimers: true });
|
||||||
|
mockMobilePlatform('web');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
screen.unmount();
|
||||||
|
resetMobilePlatform();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ platform: 'android', shouldShow: false },
|
||||||
|
{ platform: 'web', shouldShow: true },
|
||||||
|
{ platform: 'ios', shouldShow: false },
|
||||||
|
])('should correctly show/hide the feedback banner on %s', ({ platform, shouldShow }) => {
|
||||||
|
mockMobilePlatform(platform);
|
||||||
|
|
||||||
|
render(<WrappedFeedbackBanner />);
|
||||||
|
|
||||||
|
const header = screen.queryByRole('header', { name: 'Feedback' });
|
||||||
|
if (shouldShow) {
|
||||||
|
expect(header).toBeVisible();
|
||||||
|
} else {
|
||||||
|
expect(header).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the "Useful" button should submit the response and show the "take survey" link', async () => {
|
||||||
|
const feedbackServerMock = mockFeedbackServer();
|
||||||
|
render(<WrappedFeedbackBanner />);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usefulButton = getFeedbackButton(true);
|
||||||
|
fireEvent.press(usefulButton);
|
||||||
|
|
||||||
|
await act(() => waitFor(async () => {
|
||||||
|
expect(getSurveyLink()).toBeVisible();
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(feedbackServerMock).toMatchObject({
|
||||||
|
helpfulCount: 1,
|
||||||
|
unhelpfulCount: 0,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
feedbackServerMock.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { View, StyleSheet, useWindowDimensions, TextStyle, Linking } from 'react-native';
|
||||||
|
import { Portal, Text } from 'react-native-paper';
|
||||||
|
import IconButton from './IconButton';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import shim from '@joplin/lib/shim';
|
||||||
|
import { Dispatch } from 'redux';
|
||||||
|
import { themeStyle } from './global-style';
|
||||||
|
import { AppState } from '../utils/types';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
|
import { LinkButton } from './buttons';
|
||||||
|
import Logger from '@joplin/utils/Logger';
|
||||||
|
import { SurveyProgress } from '@joplin/lib/models/settings/builtInMetadata';
|
||||||
|
|
||||||
|
const logger = Logger.create('FeedbackBanner');
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dispatch: Dispatch;
|
||||||
|
progress: SurveyProgress;
|
||||||
|
surveyKey: string;
|
||||||
|
themeId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = (themeId: number, sentFeedback: boolean) => {
|
||||||
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
|
return useMemo(() => {
|
||||||
|
const theme = themeStyle(themeId);
|
||||||
|
|
||||||
|
const iconBaseStyle: TextStyle = {
|
||||||
|
fontSize: 24,
|
||||||
|
color: theme.color3,
|
||||||
|
};
|
||||||
|
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: theme.backgroundColor3,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 1,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
flexDirection: 'row',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
maxWidth: windowWidth - 50,
|
||||||
|
gap: 18,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
contentRight: {
|
||||||
|
display: sentFeedback ? 'none' : 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
iconUseful: {
|
||||||
|
...iconBaseStyle,
|
||||||
|
color: theme.colorCorrect,
|
||||||
|
},
|
||||||
|
iconNotUseful: {
|
||||||
|
...iconBaseStyle,
|
||||||
|
color: theme.colorWarn,
|
||||||
|
},
|
||||||
|
dismissButtonIcon: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: theme.color2,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginRight: 'auto',
|
||||||
|
},
|
||||||
|
dismissButton: {
|
||||||
|
backgroundColor: theme.backgroundColor2,
|
||||||
|
borderColor: theme.backgroundColor,
|
||||||
|
borderWidth: 2,
|
||||||
|
width: 29,
|
||||||
|
height: 29,
|
||||||
|
borderRadius: 14,
|
||||||
|
position: 'absolute',
|
||||||
|
top: -16,
|
||||||
|
right: -16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
dismissButtonContent: {
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [themeId, windowWidth, sentFeedback]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSurveyUrl = (surveyKey: string) => {
|
||||||
|
return useMemo(() => {
|
||||||
|
let baseUrl = 'https://objects.joplinusercontent.com/';
|
||||||
|
|
||||||
|
// For testing with a locally-hosted server:
|
||||||
|
const useLocalServer = false;
|
||||||
|
if (Setting.value('env') === 'dev' && useLocalServer) {
|
||||||
|
baseUrl = 'http://localhost:3430/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseUrl}r/survey--${encodeURIComponent(surveyKey)}`;
|
||||||
|
}, [surveyKey]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setProgress = (progress: SurveyProgress) => {
|
||||||
|
Setting.setValue('survey.webClientEval2025.progress', progress);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDismiss = () => {
|
||||||
|
setProgress(SurveyProgress.Dismissed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FeedbackBanner: React.FC<Props> = props => {
|
||||||
|
const surveyUrl = useSurveyUrl(props.surveyKey);
|
||||||
|
const sentFeedback = props.progress === SurveyProgress.Started;
|
||||||
|
|
||||||
|
const sendSurveyResponse = useCallback(async (surveyResponse: string) => {
|
||||||
|
const fetchUrl = `${surveyUrl}--${encodeURIComponent(surveyResponse)}`;
|
||||||
|
logger.debug('sending response to', fetchUrl);
|
||||||
|
const showError = (message: string) => {
|
||||||
|
logger.error('Error', message);
|
||||||
|
void shim.showErrorDialog(
|
||||||
|
_('An error occurred while sending the response. This can happen if the app is offline or cannot connect to the server.\nError: %s', message),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await shim.fetch(fetchUrl);
|
||||||
|
// The server currently redirects (status 302) in response
|
||||||
|
// to many survey-related requests. This may be returned by
|
||||||
|
// the web app service worker as a 200 OK response, however. Support both:
|
||||||
|
if (response.ok || response.status === 302) {
|
||||||
|
setProgress(SurveyProgress.Started);
|
||||||
|
} else {
|
||||||
|
const body = await response.text();
|
||||||
|
showError(`Server error: ${response.status} ${body}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
}
|
||||||
|
}, [surveyUrl]);
|
||||||
|
|
||||||
|
const onSurveyLinkClick = useCallback(() => {
|
||||||
|
void Linking.openURL(surveyUrl);
|
||||||
|
onDismiss();
|
||||||
|
}, [surveyUrl]);
|
||||||
|
|
||||||
|
const onNotUsefulClick = useCallback(() => {
|
||||||
|
void sendSurveyResponse('unhelpful');
|
||||||
|
}, [sendSurveyResponse]);
|
||||||
|
|
||||||
|
const onUsefulClick = useCallback(() => {
|
||||||
|
void sendSurveyResponse('helpful');
|
||||||
|
}, [sendSurveyResponse]);
|
||||||
|
|
||||||
|
const styles = useStyles(props.themeId, sentFeedback);
|
||||||
|
|
||||||
|
const renderStatusMessage = () => {
|
||||||
|
if (sentFeedback) {
|
||||||
|
return <View>
|
||||||
|
<Text>{_('Thank you for the feedback!\nDo you have time to complete a short survey?')}</Text>
|
||||||
|
<LinkButton onPress={onSurveyLinkClick}>{_('Take survey')}</LinkButton>
|
||||||
|
</View>;
|
||||||
|
} else {
|
||||||
|
return <Text>{_('Do you find the Joplin web app useful?')}</Text>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shim.mobilePlatform() !== 'web' || props.progress === SurveyProgress.Dismissed) return null;
|
||||||
|
|
||||||
|
return <Portal>
|
||||||
|
<View style={styles.container} role='complementary'>
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
accessibilityRole='header'
|
||||||
|
variant='titleMedium'
|
||||||
|
style={styles.header}
|
||||||
|
>{_('Feedback')}</Text>
|
||||||
|
<Text>{renderStatusMessage()}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.contentRight}>
|
||||||
|
<IconButton
|
||||||
|
iconName='fas times'
|
||||||
|
themeId={props.themeId}
|
||||||
|
onPress={onNotUsefulClick}
|
||||||
|
description={_('Not useful')}
|
||||||
|
iconStyle={styles.iconNotUseful}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
iconName='fas check'
|
||||||
|
themeId={props.themeId}
|
||||||
|
onPress={onUsefulClick}
|
||||||
|
description={_('Useful')}
|
||||||
|
iconStyle={styles.iconUseful}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<IconButton
|
||||||
|
iconName='fas times'
|
||||||
|
themeId={props.themeId}
|
||||||
|
onPress={onDismiss}
|
||||||
|
description={_('Dismiss')}
|
||||||
|
iconStyle={styles.dismissButtonIcon}
|
||||||
|
contentWrapperStyle={styles.dismissButtonContent}
|
||||||
|
containerStyle={styles.dismissButton}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Portal>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect((state: AppState) => ({
|
||||||
|
themeId: state.settings.theme,
|
||||||
|
surveyKey: 'web-app-test',
|
||||||
|
progress: state.settings['survey.webClientEval2025.progress'],
|
||||||
|
}))(FeedbackBanner);
|
|
@ -87,6 +87,14 @@ const IconButton = (props: ButtonProps) => {
|
||||||
props.preventKeyboardDismiss, props.onPress, props.disabled,
|
props.preventKeyboardDismiss, props.onPress, props.disabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let icon = <Icon
|
||||||
|
name={props.iconName}
|
||||||
|
style={props.iconStyle}
|
||||||
|
accessibilityLabel={null}
|
||||||
|
/>;
|
||||||
|
// Include browser-provided tooltips on web.
|
||||||
|
icon = Platform.OS === 'web' ? <span title={props.description}>{icon}</span> : icon;
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={props.pressableRef}
|
ref={props.pressableRef}
|
||||||
|
@ -115,11 +123,7 @@ const IconButton = (props: ButtonProps) => {
|
||||||
opacity: fadeAnim,
|
opacity: fadeAnim,
|
||||||
...props.contentWrapperStyle,
|
...props.contentWrapperStyle,
|
||||||
}}>
|
}}>
|
||||||
<Icon
|
{icon}
|
||||||
name={props.iconName}
|
|
||||||
style={props.iconStyle}
|
|
||||||
accessibilityLabel={null}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|
|
@ -84,7 +84,7 @@ const NestableFlatList = function<T>({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const bufferSize = 10;
|
const bufferSize = 10;
|
||||||
const visibleStartIndex = Math.floor(scroll / itemHeight);
|
const visibleStartIndex = Math.min(Math.floor(scroll / itemHeight), data.length);
|
||||||
const visibleEndIndex = Math.ceil((scroll + listHeight) / itemHeight);
|
const visibleEndIndex = Math.ceil((scroll + listHeight) / itemHeight);
|
||||||
const startIndex = Math.max(0, visibleStartIndex - bufferSize);
|
const startIndex = Math.max(0, visibleStartIndex - bufferSize);
|
||||||
const maximumIndex = data.length - 1;
|
const maximumIndex = data.length - 1;
|
||||||
|
|
|
@ -16,6 +16,12 @@ import { ResourceInfo } from './hooks/useRerenderHandler';
|
||||||
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
|
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
|
||||||
import TestProviderStack from '../testing/TestProviderStack';
|
import TestProviderStack from '../testing/TestProviderStack';
|
||||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||||
|
import Plugin from '@joplin/lib/services/plugins/Plugin';
|
||||||
|
import { Store } from 'redux';
|
||||||
|
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
|
||||||
|
import { basename, dirname, join } from 'path';
|
||||||
|
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||||
|
import mockPluginServiceSetup from '../../utils/testing/mockPluginServiceSetup';
|
||||||
|
|
||||||
interface WrapperProps {
|
interface WrapperProps {
|
||||||
noteBody: string;
|
noteBody: string;
|
||||||
|
@ -28,7 +34,7 @@ interface WrapperProps {
|
||||||
const emptyObject = {};
|
const emptyObject = {};
|
||||||
const emptyArray: string[] = [];
|
const emptyArray: string[] = [];
|
||||||
const noOpFunction = () => {};
|
const noOpFunction = () => {};
|
||||||
const testStore = createMockReduxStore();
|
let testStore: Store;
|
||||||
const WrappedNoteViewer: React.FC<WrapperProps> = (
|
const WrappedNoteViewer: React.FC<WrapperProps> = (
|
||||||
{
|
{
|
||||||
noteBody,
|
noteBody,
|
||||||
|
@ -58,10 +64,34 @@ const getNoteViewerDom = async () => {
|
||||||
return await getWebViewDomById('NoteBodyViewer');
|
return await getWebViewDomById('NoteBodyViewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadTestContentScript = async (path: string, pluginId: string) => {
|
||||||
|
const plugin = new Plugin(
|
||||||
|
dirname(path),
|
||||||
|
{
|
||||||
|
manifest_version: 1,
|
||||||
|
id: pluginId,
|
||||||
|
name: 'Test plugin',
|
||||||
|
version: '1',
|
||||||
|
app_min_version: '1',
|
||||||
|
},
|
||||||
|
'',
|
||||||
|
testStore.dispatch,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
await PluginService.instance().runPlugin(plugin);
|
||||||
|
await plugin.registerContentScript(
|
||||||
|
ContentScriptType.MarkdownItPlugin,
|
||||||
|
`${pluginId}-markdown-it`,
|
||||||
|
basename(path),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
describe('NoteBodyViewer', () => {
|
describe('NoteBodyViewer', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await setupDatabaseAndSynchronizer(0);
|
await setupDatabaseAndSynchronizer(0);
|
||||||
await switchClient(0);
|
await switchClient(0);
|
||||||
|
testStore = createMockReduxStore();
|
||||||
|
mockPluginServiceSetup(testStore);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -85,6 +115,17 @@ describe('NoteBodyViewer', () => {
|
||||||
await expectHeaderToBe('Test 3');
|
await expectHeaderToBe('Test 3');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should support basic renderer plugins', async () => {
|
||||||
|
await loadTestContentScript(join(supportDir, 'plugins', 'markdownItTestPlugin.js'), 'test-plugin');
|
||||||
|
|
||||||
|
render(<WrappedNoteViewer noteBody={'```justtesting\ntest\n```\n'}/>);
|
||||||
|
|
||||||
|
const noteViewer = await getNoteViewerDom();
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect(noteViewer.querySelector('div.just-testing')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ keywords: ['match'], body: 'A match and another match. Both should be highlighted.', expectedMatchCount: 2 },
|
{ keywords: ['match'], body: 'A match and another match. Both should be highlighted.', expectedMatchCount: 2 },
|
||||||
{ keywords: ['test'], body: 'No match.', expectedMatchCount: 0 },
|
{ keywords: ['test'], body: 'No match.', expectedMatchCount: 0 },
|
||||||
|
|
|
@ -230,8 +230,8 @@ const useEditorControl = (
|
||||||
setSearchState: setSearchStateCallback,
|
setSearchState: setSearchStateCallback,
|
||||||
},
|
},
|
||||||
|
|
||||||
onResourceDownloaded: (id: string) => {
|
onResourceChanged: (id: string) => {
|
||||||
editorRef.current.onResourceDownloaded(id);
|
editorRef.current.onResourceChanged(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
remove: () => {
|
remove: () => {
|
||||||
|
@ -342,10 +342,18 @@ function NoteEditor(props: Props) {
|
||||||
const isDownloaded = (resourceInfos: ResourceInfos, resourceId: string) => {
|
const isDownloaded = (resourceInfos: ResourceInfos, resourceId: string) => {
|
||||||
return resourceInfos[resourceId]?.localState?.fetch_status === Resource.FETCH_STATUS_DONE;
|
return resourceInfos[resourceId]?.localState?.fetch_status === Resource.FETCH_STATUS_DONE;
|
||||||
};
|
};
|
||||||
|
const isEncrypted = (resourceInfos: ResourceInfos, resourceId: string) => {
|
||||||
|
return resourceInfos[resourceId]?.item?.encryption_blob_encrypted === 1;
|
||||||
|
};
|
||||||
for (const key in props.noteResources) {
|
for (const key in props.noteResources) {
|
||||||
const wasDownloaded = isDownloaded(lastNoteResources.current, key);
|
const wasDownloaded = isDownloaded(lastNoteResources.current, key);
|
||||||
if (!wasDownloaded && isDownloaded(props.noteResources, key)) {
|
const hasDownloaded = !wasDownloaded && isDownloaded(props.noteResources, key);
|
||||||
editorControl.onResourceDownloaded(key);
|
|
||||||
|
const wasEncrypted = isEncrypted(lastNoteResources.current, key);
|
||||||
|
const hasDecrypted = wasEncrypted && !isEncrypted(props.noteResources, key);
|
||||||
|
|
||||||
|
if (hasDownloaded || hasDecrypted) {
|
||||||
|
editorControl.onResourceChanged(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [props.noteResources, editorControl]);
|
}, [props.noteResources, editorControl]);
|
||||||
|
|
|
@ -257,7 +257,7 @@ describe('RichTextEditor', () => {
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
editorRef.current.onResourceDownloaded(localResource.id);
|
editorRef.current.onResourceChanged(localResource.id);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await findElement(`img[data-resource-id=${JSON.stringify(localResource.id)}]`),
|
await findElement(`img[data-resource-id=${JSON.stringify(localResource.id)}]`),
|
||||||
|
@ -452,6 +452,8 @@ describe('RichTextEditor', () => {
|
||||||
'==highlight==ed',
|
'==highlight==ed',
|
||||||
'<sup>Super</sup>script',
|
'<sup>Super</sup>script',
|
||||||
'<sub>Sub</sub>script',
|
'<sub>Sub</sub>script',
|
||||||
|
'',
|
||||||
|
'<img src="data:image/svg+xml;utf8,test" width="120">',
|
||||||
])('should preserve inline markup on edit (case %#)', async (initialBody) => {
|
])('should preserve inline markup on edit (case %#)', async (initialBody) => {
|
||||||
initialBody += 'test'; // Ensure that typing will add new content outside the formatting
|
initialBody += 'test'; // Ensure that typing will add new content outside the formatting
|
||||||
let body = initialBody;
|
let body = initialBody;
|
||||||
|
|
|
@ -37,6 +37,7 @@ const useStyles = (themeId: number) => {
|
||||||
|
|
||||||
const listItemPressable: ViewStyle = {
|
const listItemPressable: ViewStyle = {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
|
flexShrink: 1,
|
||||||
alignSelf: 'stretch',
|
alignSelf: 'stretch',
|
||||||
};
|
};
|
||||||
const listItemPressableWithCheckbox: ViewStyle = {
|
const listItemPressableWithCheckbox: ViewStyle = {
|
||||||
|
|
|
@ -7,6 +7,8 @@ import AccessibleView from '../accessibility/AccessibleView';
|
||||||
import debounce from '../../utils/debounce';
|
import debounce from '../../utils/debounce';
|
||||||
import FocusControl from '../accessibility/FocusControl/FocusControl';
|
import FocusControl from '../accessibility/FocusControl/FocusControl';
|
||||||
import { ModalState } from '../accessibility/FocusControl/types';
|
import { ModalState } from '../accessibility/FocusControl/types';
|
||||||
|
import useKeyboardState from '../../utils/hooks/useKeyboardState';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
interface MenuOptionDivider {
|
interface MenuOptionDivider {
|
||||||
isDivider: true;
|
isDivider: true;
|
||||||
|
@ -29,7 +31,9 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = (themeId: number) => {
|
const useStyles = (themeId: number) => {
|
||||||
const { height: windowHeight } = useWindowDimensions();
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||||
|
const safeAreaInsets = useSafeAreaInsets();
|
||||||
|
const { dockedKeyboardHeight: keyboardHeight } = useKeyboardState();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const theme = themeStyle(themeId);
|
const theme = themeStyle(themeId);
|
||||||
|
@ -46,6 +50,20 @@ const useStyles = (themeId: number) => {
|
||||||
fontSize: theme.fontSize,
|
fontSize: theme.fontSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLandscape = windowWidth > windowHeight;
|
||||||
|
const extraPadding = isLandscape ? 25 : 50;
|
||||||
|
|
||||||
|
// When a docked on-screen keyboard is showing, we want to maximise the height of the menu as much as possible, due to the limited available space.
|
||||||
|
// However, when the on-screen keyboard is hidden or floating in either portrait or landscape orientation, it is less of an issue to have excess in the amount
|
||||||
|
// of padding, to ensure nothing is cut off on all varieties of supported mobile platforms with different input and navigation bar settings. In particular,
|
||||||
|
// on Android it is not possible to distinguish between a floating keyboard and a horizontal input bar which is docked, but the latter requires a larger
|
||||||
|
// reduction in height. For this reason we use a fixed value for insetOrExtraFullscreenPadding when the keyboard height is zero. However, Android versions
|
||||||
|
// earlier than 15 have an IME toolbar in addition to the input toolbar when using an external keyboard, so to cater for this scenario, we can use the fixed
|
||||||
|
// value if the keyboardHeight is <= 25 (as any proper on-screen keyboard would be much taller than this). If the keyboard height is larger than this, we can assume
|
||||||
|
// a docked keyboard is visible, so we only need cater for the insets in addition to the fixed extraPadding required for compatibility across Android versions
|
||||||
|
const insetOrExtraFullscreenPadding = keyboardHeight <= 25 ? 70 : safeAreaInsets.top + safeAreaInsets.bottom;
|
||||||
|
const maxMenuHeight = windowHeight - keyboardHeight - extraPadding - insetOrExtraFullscreenPadding;
|
||||||
|
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
divider: {
|
divider: {
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
|
@ -66,13 +84,13 @@ const useStyles = (themeId: number) => {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
menuContentScroller: {
|
menuContentScroller: {
|
||||||
maxHeight: windowHeight - 50,
|
maxHeight: maxMenuHeight,
|
||||||
},
|
},
|
||||||
contextMenuButton: {
|
contextMenuButton: {
|
||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [themeId, windowHeight]);
|
}, [themeId, windowWidth, windowHeight, safeAreaInsets, keyboardHeight]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MenuComponent: React.FC<Props> = props => {
|
const MenuComponent: React.FC<Props> = props => {
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Linking, TextStyle, View, ViewStyle } from 'react-native';
|
import { Linking, StyleSheet, TextStyle, View, ViewStyle } from 'react-native';
|
||||||
import { Text } from 'react-native-paper';
|
import { Text } from 'react-native-paper';
|
||||||
import IconButton from '../IconButton';
|
import IconButton from '../IconButton';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
|
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
|
||||||
import { LinkButton } from '../buttons';
|
import { LinkButton } from '../buttons';
|
||||||
import NavService from '@joplin/lib/services/NavService';
|
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
|
||||||
|
import getPackageInfo from '../../utils/getPackageInfo';
|
||||||
|
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||||
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
wrapperStyle: ViewStyle;
|
wrapperStyle: ViewStyle;
|
||||||
|
@ -15,10 +18,24 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLeaveFeedback = () => {
|
const onLeaveFeedback = () => {
|
||||||
void Linking.openURL('https://discourse.joplinapp.org/t/web-client-running-joplin-mobile-in-a-web-browser-with-react-native-web/38749');
|
void Linking.openURL('https://forms.gle/B5YGDNzsUYBnoPx19');
|
||||||
};
|
};
|
||||||
|
|
||||||
const feedbackContainerStyles: ViewStyle = { flexGrow: 1, justifyContent: 'flex-end' };
|
const onReportBug = () => {
|
||||||
|
void Linking.openURL(
|
||||||
|
makeDiscourseDebugUrl('', '', [], getPackageInfo(), PluginService.instance(), Setting.value('plugins.states')),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
feedbackContainer: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
paragraph: {
|
||||||
|
paddingBottom: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const WebBetaButton: React.FC<Props> = props => {
|
const WebBetaButton: React.FC<Props> = props => {
|
||||||
const [dialogVisible, setDialogVisible] = useState(false);
|
const [dialogVisible, setDialogVisible] = useState(false);
|
||||||
|
@ -31,6 +48,10 @@ const WebBetaButton: React.FC<Props> = props => {
|
||||||
setDialogVisible(false);
|
setDialogVisible(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const renderParagraph = (content: string) => {
|
||||||
|
return <Text variant='bodyLarge' style={styles.paragraph}>{content}</Text>;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@ -49,10 +70,13 @@ const WebBetaButton: React.FC<Props> = props => {
|
||||||
visible={dialogVisible}
|
visible={dialogVisible}
|
||||||
onDismiss={onHideDialog}
|
onDismiss={onHideDialog}
|
||||||
>
|
>
|
||||||
<Text>{'At present, the web client is in beta. In the future, it may change significantly, or be removed.'}</Text>
|
{renderParagraph('Welcome to the beta version of the Joplin Web App!')}
|
||||||
<View style={feedbackContainerStyles}>
|
{renderParagraph('Thank you for participating in the beta version of the Joplin Web App.')}
|
||||||
|
{renderParagraph('The Joplin Web App is available for a limited time in open beta and may later join the Joplin Cloud plans.')}
|
||||||
|
{renderParagraph('Feel free to use it and let us know if have any questions or notice any issues!')}
|
||||||
|
<View style={styles.feedbackContainer}>
|
||||||
|
<LinkButton onPress={onReportBug}>{'Report bug'}</LinkButton>
|
||||||
<LinkButton onPress={onLeaveFeedback}>{'Give feedback'}</LinkButton>
|
<LinkButton onPress={onLeaveFeedback}>{'Give feedback'}</LinkButton>
|
||||||
<LinkButton onPress={() => NavService.go('DocumentScanner')}>{'Test work-in-progress feature: Document scanner'}</LinkButton>
|
|
||||||
</View>
|
</View>
|
||||||
</DismissibleDialog>
|
</DismissibleDialog>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -89,7 +89,7 @@ describe('TagEditor', () => {
|
||||||
|
|
||||||
const searchResult = screen.getByRole('button', { name: 'new tag 1' });
|
const searchResult = screen.getByRole('button', { name: 'new tag 1' });
|
||||||
fireEvent.press(searchResult);
|
fireEvent.press(searchResult);
|
||||||
expect(currentTags).toEqual(['test', 'new tag 1']);
|
expect(currentTags).toEqual(['new tag 1', 'test']);
|
||||||
|
|
||||||
// Manually unmount to prevent warnings
|
// Manually unmount to prevent warnings
|
||||||
unmount();
|
unmount();
|
||||||
|
@ -115,7 +115,7 @@ describe('TagEditor', () => {
|
||||||
|
|
||||||
const addNewButton = screen.getByRole('button', { name: 'Add new' });
|
const addNewButton = screen.getByRole('button', { name: 'Add new' });
|
||||||
fireEvent.press(addNewButton);
|
fireEvent.press(addNewButton);
|
||||||
expect(currentTags).toEqual(['test', 'create']);
|
expect(currentTags).toEqual(['create', 'test']);
|
||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { TagEntity } from '@joplin/lib/services/database/types';
|
||||||
import { Divider } from 'react-native-paper';
|
import { Divider } from 'react-native-paper';
|
||||||
import focusView from '../utils/focusView';
|
import focusView from '../utils/focusView';
|
||||||
import { msleep } from '@joplin/utils/time';
|
import { msleep } from '@joplin/utils/time';
|
||||||
|
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
|
||||||
|
|
||||||
export enum TagEditorMode {
|
export enum TagEditorMode {
|
||||||
Large,
|
Large,
|
||||||
|
@ -38,11 +39,13 @@ const useStyles = (themeId: number, headerStyle: TextStyle|undefined) => {
|
||||||
color: theme.color3,
|
color: theme.color3,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
maxWidth: '100%',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
},
|
},
|
||||||
tagText: {
|
tagText: {
|
||||||
color: theme.color3,
|
color: theme.color3,
|
||||||
fontSize: theme.fontSize,
|
fontSize: theme.fontSize,
|
||||||
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
removeTagButton: {
|
removeTagButton: {
|
||||||
color: theme.color3,
|
color: theme.color3,
|
||||||
|
@ -122,7 +125,11 @@ const TagCard: React.FC<TagChipProps> = props => {
|
||||||
style={props.styles.tag}
|
style={props.styles.tag}
|
||||||
role='listitem'
|
role='listitem'
|
||||||
>
|
>
|
||||||
<Text style={props.styles.tagText}>{props.title}</Text>
|
<Text
|
||||||
|
ellipsizeMode='tail'
|
||||||
|
numberOfLines={1}
|
||||||
|
style={props.styles.tagText}
|
||||||
|
>{props.title}</Text>
|
||||||
<IconButton
|
<IconButton
|
||||||
pressableRef={removeButtonRef}
|
pressableRef={removeButtonRef}
|
||||||
themeId={props.themeId}
|
themeId={props.themeId}
|
||||||
|
@ -144,23 +151,32 @@ interface TagsBoxProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const TagsBox: React.FC<TagsBoxProps> = props => {
|
const TagsBox: React.FC<TagsBoxProps> = props => {
|
||||||
|
const collatorLocale = getCollatorLocale();
|
||||||
|
const collator = useMemo(() => {
|
||||||
|
return getCollator(collatorLocale);
|
||||||
|
}, [collatorLocale]);
|
||||||
|
|
||||||
const onRemoveTag = useCallback((tag: string) => {
|
const onRemoveTag = useCallback((tag: string) => {
|
||||||
props.onRemoveTag(tag);
|
props.onRemoveTag(tag);
|
||||||
}, [props.onRemoveTag]);
|
}, [props.onRemoveTag]);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (props.tags.length) {
|
if (props.tags.length) {
|
||||||
return props.tags.map(tag => (
|
return props.tags
|
||||||
<TagCard
|
.sort((a, b) => {
|
||||||
key={`tag-${tag}`}
|
return collator.compare(a, b);
|
||||||
title={tag}
|
})
|
||||||
styles={props.styles}
|
.map(tag => (
|
||||||
themeId={props.themeId}
|
<TagCard
|
||||||
onRemove={onRemoveTag}
|
key={`tag-${tag}`}
|
||||||
autofocus={props.autofocusTag === tag}
|
title={tag}
|
||||||
onAutoFocusComplete={props.onAutoFocusComplete}
|
styles={props.styles}
|
||||||
/>
|
themeId={props.themeId}
|
||||||
));
|
onRemove={onRemoveTag}
|
||||||
|
autofocus={props.autofocusTag === tag}
|
||||||
|
onAutoFocusComplete={props.onAutoFocusComplete}
|
||||||
|
/>
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
return <Text
|
return <Text
|
||||||
style={props.styles.noTagsLabel}
|
style={props.styles.noTagsLabel}
|
||||||
|
@ -189,15 +205,13 @@ const TagsBox: React.FC<TagsBoxProps> = props => {
|
||||||
</View>;
|
</View>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeTag = (tagText: string) => tagText.trim().toLowerCase();
|
|
||||||
|
|
||||||
const TagEditor: React.FC<Props> = props => {
|
const TagEditor: React.FC<Props> = props => {
|
||||||
const styles = useStyles(props.themeId, props.headerStyle);
|
const styles = useStyles(props.themeId, props.headerStyle);
|
||||||
|
|
||||||
const comboBoxItems = useMemo(() => {
|
const comboBoxItems = useMemo(() => {
|
||||||
return props.allTags
|
return props.allTags
|
||||||
// Exclude tags already associated with the note
|
// Exclude tags already associated with the note
|
||||||
.filter(tag => !props.tags.includes(tag.title))
|
.filter(tag => !props.tags.some(o => o.toLowerCase() === tag.title?.toLowerCase()))
|
||||||
.map((tag): Option => {
|
.map((tag): Option => {
|
||||||
const title = tag.title ?? 'Untitled';
|
const title = tag.title ?? 'Untitled';
|
||||||
return {
|
return {
|
||||||
|
@ -217,11 +231,13 @@ const TagEditor: React.FC<Props> = props => {
|
||||||
|
|
||||||
const onAddTag = useCallback((title: string) => {
|
const onAddTag = useCallback((title: string) => {
|
||||||
AccessibilityInfo.announceForAccessibility(_('Added tag: %s', title));
|
AccessibilityInfo.announceForAccessibility(_('Added tag: %s', title));
|
||||||
props.onTagsChange([...props.tags, normalizeTag(title)]);
|
props.onTagsChange([...props.tags, title.trim()]);
|
||||||
}, [props.tags, props.onTagsChange]);
|
}, [props.tags, props.onTagsChange]);
|
||||||
|
|
||||||
const onRemoveTag = useCallback(async (title: string) => {
|
const onRemoveTag = useCallback(async (title: string) => {
|
||||||
const previousTagIndex = props.tags.indexOf(title);
|
if (!title) return;
|
||||||
|
const lowercaseTitle = title.toLowerCase();
|
||||||
|
const previousTagIndex = props.tags.findIndex(item => item.toLowerCase() === lowercaseTitle);
|
||||||
const targetTag = props.tags[previousTagIndex + 1] ?? props.tags[previousTagIndex - 1];
|
const targetTag = props.tags[previousTagIndex + 1] ?? props.tags[previousTagIndex - 1];
|
||||||
setAutofocusTag(targetTag);
|
setAutofocusTag(targetTag);
|
||||||
|
|
||||||
|
@ -229,7 +245,7 @@ const TagEditor: React.FC<Props> = props => {
|
||||||
// prevent focus from occasionally jumping away from the tag box.
|
// prevent focus from occasionally jumping away from the tag box.
|
||||||
await msleep(100);
|
await msleep(100);
|
||||||
AccessibilityInfo.announceForAccessibility(_('Removed tag: %s', title));
|
AccessibilityInfo.announceForAccessibility(_('Removed tag: %s', title));
|
||||||
props.onTagsChange(props.tags.filter(tag => tag !== title));
|
props.onTagsChange(props.tags.filter(tag => tag.toLowerCase() !== lowercaseTitle));
|
||||||
}, [props.tags, props.onTagsChange]);
|
}, [props.tags, props.onTagsChange]);
|
||||||
|
|
||||||
const onComboBoxSelect = useCallback((item: { title: string }) => {
|
const onComboBoxSelect = useCallback((item: { title: string }) => {
|
||||||
|
@ -237,16 +253,16 @@ const TagEditor: React.FC<Props> = props => {
|
||||||
return { willRemove: true };
|
return { willRemove: true };
|
||||||
}, [onAddTag]);
|
}, [onAddTag]);
|
||||||
|
|
||||||
const allTagsSet = useMemo(() => {
|
const allTagsSetNormalized = useMemo(() => {
|
||||||
return new Set([
|
return new Set([
|
||||||
...props.allTags.map(tag => tag.title),
|
...props.allTags.map(tag => tag.title?.trim()?.toLowerCase()),
|
||||||
...props.tags,
|
...props.tags.map(tag => tag.trim().toLowerCase()),
|
||||||
]);
|
]);
|
||||||
}, [props.allTags, props.tags]);
|
}, [props.allTags, props.tags]);
|
||||||
|
|
||||||
const onCanAddTag = useCallback((tag: string) => {
|
const onCanAddTag = useCallback((tag: string) => {
|
||||||
return !allTagsSet.has(normalizeTag(tag));
|
return !allTagsSetNormalized.has(tag.trim().toLowerCase());
|
||||||
}, [allTagsSet]);
|
}, [allTagsSetNormalized]);
|
||||||
|
|
||||||
const showAssociatedTags = props.mode === TagEditorMode.Large || props.tags.length > 0;
|
const showAssociatedTags = props.mode === TagEditorMode.Large || props.tags.length > 0;
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { themeStyle } from './global-style';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import useKeyboardState from '../utils/hooks/useKeyboardState';
|
import useKeyboardState from '../utils/hooks/useKeyboardState';
|
||||||
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
||||||
|
import FeedbackBanner from './FeedbackBanner';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
|
@ -67,6 +68,7 @@ const AppNavComponent: React.FC<Props> = (props) => {
|
||||||
<NotesScreen visible={notesScreenVisible} />
|
<NotesScreen visible={notesScreenVisible} />
|
||||||
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
|
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
|
||||||
{!notesScreenVisible && !searchScreenVisible && <Screen navigation={{ state: route }} themeId={props.themeId} dispatch={props.dispatch} />}
|
{!notesScreenVisible && !searchScreenVisible && <Screen navigation={{ state: route }} themeId={props.themeId} dispatch={props.dispatch} />}
|
||||||
|
{notesScreenVisible ? <FeedbackBanner/> : null}
|
||||||
<View style={{ height: autocompletionBarPadding }} />
|
<View style={{ height: autocompletionBarPadding }} />
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,11 +6,12 @@ import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||||
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
|
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
|
||||||
import PluginRunnerWebView from './PluginRunnerWebView';
|
import PluginRunnerWebView from './PluginRunnerWebView';
|
||||||
import TestProviderStack from '../testing/TestProviderStack';
|
import TestProviderStack from '../testing/TestProviderStack';
|
||||||
import { render, waitFor } from '../../utils/testing/testingLibrary';
|
import { act, render, screen, waitFor } from '../../utils/testing/testingLibrary';
|
||||||
import createTestPlugin from '@joplin/lib/testing/plugins/createTestPlugin';
|
import createTestPlugin from '@joplin/lib/testing/plugins/createTestPlugin';
|
||||||
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
|
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||||
|
import CommandService from '@joplin/lib/services/CommandService';
|
||||||
|
|
||||||
let store: Store<AppState>;
|
let store: Store<AppState>;
|
||||||
|
|
||||||
|
@ -30,6 +31,16 @@ const defaultManifestProperties = {
|
||||||
name: 'Some plugin name',
|
name: 'Some plugin name',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PluginSlice = { manifest: { id: string } };
|
||||||
|
const waitForPluginToLoad = (plugin: PluginSlice) => {
|
||||||
|
return waitFor(async () => {
|
||||||
|
expect(PluginService.instance().pluginById(plugin.manifest.id)).toBeTruthy();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const webViewId = 'joplin__PluginDialogWebView';
|
||||||
|
const getUserWebViewDom = () => getWebViewDomById(webViewId);
|
||||||
|
|
||||||
describe('PluginRunnerWebView', () => {
|
describe('PluginRunnerWebView', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await setupDatabaseAndSynchronizer(0);
|
await setupDatabaseAndSynchronizer(0);
|
||||||
|
@ -56,16 +67,68 @@ describe('PluginRunnerWebView', () => {
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
render(<WrappedPluginRunnerWebView/>);
|
render(<WrappedPluginRunnerWebView/>);
|
||||||
|
await waitForPluginToLoad(testPlugin);
|
||||||
// Should load the plugin
|
|
||||||
await waitFor(async () => {
|
|
||||||
expect(PluginService.instance().pluginById(testPlugin.manifest.id)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should show the dialog
|
// Should show the dialog
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const dom = await getWebViewDomById('joplin__PluginDialogWebView');
|
const dom = await getUserWebViewDom();
|
||||||
expect(dom.querySelector('h1').textContent).toBe('Test!');
|
expect(dom.querySelector('h1').textContent).toBe('Test!');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should load a plugin that adds a panel', async () => {
|
||||||
|
const testPlugin = await createTestPlugin({
|
||||||
|
...defaultManifestProperties,
|
||||||
|
id: 'org.joplinapp.panel-test',
|
||||||
|
}, {
|
||||||
|
onStart: `
|
||||||
|
const panels = joplin.views.panels;
|
||||||
|
const handle = await panels.create('test-panel');
|
||||||
|
await panels.setHtml(
|
||||||
|
handle,
|
||||||
|
'<h1>Panel content</h1><p>Test</p>',
|
||||||
|
);
|
||||||
|
|
||||||
|
const commands = joplin.commands;
|
||||||
|
await commands.register({
|
||||||
|
name: 'hideTestPanel',
|
||||||
|
label: 'Hide the test plugin panel',
|
||||||
|
execute: async () => {
|
||||||
|
await panels.hide(handle);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await commands.register({
|
||||||
|
name: 'showTestPanel',
|
||||||
|
execute: async () => {
|
||||||
|
await panels.show(handle);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
render(<WrappedPluginRunnerWebView/>);
|
||||||
|
await waitForPluginToLoad(testPlugin);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
store.dispatch({ type: 'SET_PLUGIN_PANELS_DIALOG_VISIBLE', visible: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectPanelVisible = async () => {
|
||||||
|
const dom = await getUserWebViewDom();
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect(dom.querySelector('h1').textContent).toBe('Panel content');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await expectPanelVisible();
|
||||||
|
|
||||||
|
// Should hide the panel
|
||||||
|
await act(() => CommandService.instance().execute('hideTestPanel'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('webViewId')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should show the panel again
|
||||||
|
await act(() => CommandService.instance().execute('showTestPanel'));
|
||||||
|
await expectPanelVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -120,7 +120,7 @@ const PluginPanelViewer: React.FC<Props> = props => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.webViewContainer}>
|
<View style={styles.webViewContainer} testID='plugin-tab-content'>
|
||||||
<PluginUserWebView
|
<PluginUserWebView
|
||||||
key={selectedTabId}
|
key={selectedTabId}
|
||||||
themeId={props.themeId}
|
themeId={props.themeId}
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, switch
|
||||||
import { act, fireEvent, render, screen, userEvent, waitFor } from '../../../../utils/testing/testingLibrary';
|
import { act, fireEvent, render, screen, userEvent, waitFor } from '../../../../utils/testing/testingLibrary';
|
||||||
|
|
||||||
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
||||||
import pluginServiceSetup from './testUtils/pluginServiceSetup';
|
|
||||||
import { writeFile } from 'fs-extra';
|
import { writeFile } from 'fs-extra';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import shim from '@joplin/lib/shim';
|
import shim from '@joplin/lib/shim';
|
||||||
|
@ -15,6 +14,7 @@ import createMockReduxStore from '../../../../utils/testing/createMockReduxStore
|
||||||
import WrappedPluginStates from './testUtils/WrappedPluginStates';
|
import WrappedPluginStates from './testUtils/WrappedPluginStates';
|
||||||
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
|
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
|
import mockPluginServiceSetup from '../../../../utils/testing/mockPluginServiceSetup';
|
||||||
|
|
||||||
|
|
||||||
let reduxStore: Store<AppState> = null;
|
let reduxStore: Store<AppState> = null;
|
||||||
|
@ -56,7 +56,7 @@ describe('PluginStates.installed', () => {
|
||||||
await setupDatabaseAndSynchronizer(0);
|
await setupDatabaseAndSynchronizer(0);
|
||||||
await switchClient(0);
|
await switchClient(0);
|
||||||
reduxStore = createMockReduxStore();
|
reduxStore = createMockReduxStore();
|
||||||
pluginServiceSetup(reduxStore);
|
mockPluginServiceSetup(reduxStore);
|
||||||
resetRepoApi();
|
resetRepoApi();
|
||||||
|
|
||||||
await mockMobilePlatform('android');
|
await mockMobilePlatform('android');
|
||||||
|
|
|
@ -3,13 +3,13 @@ import { mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '
|
||||||
|
|
||||||
import { render, screen, userEvent, waitFor } from '../../../../utils/testing/testingLibrary';
|
import { render, screen, userEvent, waitFor } from '../../../../utils/testing/testingLibrary';
|
||||||
|
|
||||||
import pluginServiceSetup from './testUtils/pluginServiceSetup';
|
|
||||||
import createMockReduxStore from '../../../../utils/testing/createMockReduxStore';
|
import createMockReduxStore from '../../../../utils/testing/createMockReduxStore';
|
||||||
import WrappedPluginStates from './testUtils/WrappedPluginStates';
|
import WrappedPluginStates from './testUtils/WrappedPluginStates';
|
||||||
import { AppState } from '../../../../utils/types';
|
import { AppState } from '../../../../utils/types';
|
||||||
import { Store } from 'redux';
|
import { Store } from 'redux';
|
||||||
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
|
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
|
||||||
import { resetRepoApi } from './utils/useRepoApi';
|
import { resetRepoApi } from './utils/useRepoApi';
|
||||||
|
import mockPluginServiceSetup from '../../../../utils/testing/mockPluginServiceSetup';
|
||||||
|
|
||||||
const expectSearchResultCountToBe = async (count: number) => {
|
const expectSearchResultCountToBe = async (count: number) => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
@ -37,7 +37,7 @@ describe('PluginStates.search', () => {
|
||||||
await setupDatabaseAndSynchronizer(0);
|
await setupDatabaseAndSynchronizer(0);
|
||||||
await switchClient(0);
|
await switchClient(0);
|
||||||
reduxStore = createMockReduxStore();
|
reduxStore = createMockReduxStore();
|
||||||
pluginServiceSetup(reduxStore);
|
mockPluginServiceSetup(reduxStore);
|
||||||
mockMobilePlatform('android');
|
mockMobilePlatform('android');
|
||||||
resetRepoApi();
|
resetRepoApi();
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
'use strict';
|
||||||
|
Object.defineProperty(exports, '__esModule', { value: true });
|
||||||
|
const PluginService_1 = require('@joplin/lib/services/plugins/PluginService');
|
||||||
|
const BasePluginRunner_1 = require('@joplin/lib/services/plugins/BasePluginRunner');
|
||||||
|
class MockPluginRunner extends BasePluginRunner_1.default {
|
||||||
|
async run() { }
|
||||||
|
async stop() { }
|
||||||
|
}
|
||||||
|
const pluginServiceSetup = (store) => {
|
||||||
|
const runner = new MockPluginRunner();
|
||||||
|
PluginService_1.default.instance().initialize('2.14.0', { joplin: {} }, runner, store);
|
||||||
|
};
|
||||||
|
exports.default = pluginServiceSetup;
|
||||||
|
// # sourceMappingURL=pluginServiceSetup.js.map
|
|
@ -73,6 +73,7 @@ import { defaultWindowId } from '@joplin/lib/reducer';
|
||||||
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
|
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
|
||||||
import { SelectionRange } from '../../../contentScripts/markdownEditorBundle/types';
|
import { SelectionRange } from '../../../contentScripts/markdownEditorBundle/types';
|
||||||
import { EditorType } from '../../NoteEditor/types';
|
import { EditorType } from '../../NoteEditor/types';
|
||||||
|
import IconButton from '../../IconButton';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
const emptyArray: any[] = [];
|
const emptyArray: any[] = [];
|
||||||
|
@ -148,6 +149,7 @@ interface State {
|
||||||
};
|
};
|
||||||
|
|
||||||
showSpeechToTextDialog: boolean;
|
showSpeechToTextDialog: boolean;
|
||||||
|
multiline: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> implements BaseNoteScreenComponent<State> {
|
class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> implements BaseNoteScreenComponent<State> {
|
||||||
|
@ -219,6 +221,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||||
},
|
},
|
||||||
|
|
||||||
showSpeechToTextDialog: false,
|
showSpeechToTextDialog: false,
|
||||||
|
multiline: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.titleTextFieldRef = React.createRef();
|
this.titleTextFieldRef = React.createRef();
|
||||||
|
@ -508,7 +511,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexBasis: 'auto',
|
flexBasis: 'auto',
|
||||||
paddingLeft: theme.marginLeft,
|
paddingLeft: theme.marginLeft,
|
||||||
paddingRight: theme.marginRight,
|
|
||||||
borderBottomColor: theme.dividerColor,
|
borderBottomColor: theme.dividerColor,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
};
|
};
|
||||||
|
@ -528,6 +530,16 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||||
paddingBottom: 10, // Added for iOS (Not needed for Android??)
|
paddingBottom: 10, // Added for iOS (Not needed for Android??)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
styles.titleToggleIcon = {
|
||||||
|
color: theme.colorFaded,
|
||||||
|
fontSize: 30,
|
||||||
|
height: 48,
|
||||||
|
width: 48,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
textAlign: 'center',
|
||||||
|
alignContent: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
this.styles_[cacheKey] = StyleSheet.create(styles);
|
this.styles_[cacheKey] = StyleSheet.create(styles);
|
||||||
return this.styles_[cacheKey];
|
return this.styles_[cacheKey];
|
||||||
}
|
}
|
||||||
|
@ -701,7 +713,8 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||||
}
|
}
|
||||||
|
|
||||||
private title_changeText(text: string) {
|
private title_changeText(text: string) {
|
||||||
shared.noteComponent_change(this, 'title', text);
|
const newText = text.replace(/(\r\n|\n|\r)/gm, ' ');
|
||||||
|
shared.noteComponent_change(this, 'title', newText);
|
||||||
this.setState({ newAndNoTitleChangeNoteId: null });
|
this.setState({ newAndNoTitleChangeNoteId: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1172,69 +1185,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||||
await CommandService.instance().execute('attachFile', filePath);
|
await CommandService.instance().execute('attachFile', filePath);
|
||||||
};
|
};
|
||||||
|
|
||||||
// private vosk_:Vosk;
|
|
||||||
|
|
||||||
// private async getVosk() {
|
|
||||||
// if (this.vosk_) return this.vosk_;
|
|
||||||
// this.vosk_ = new Vosk();
|
|
||||||
// await this.vosk_.loadModel('model-fr-fr');
|
|
||||||
// return this.vosk_;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// private async voiceRecording_onPress() {
|
|
||||||
// logger.info('Vosk: Getting instance...');
|
|
||||||
|
|
||||||
// const vosk = await this.getVosk();
|
|
||||||
|
|
||||||
// this.voskResult_ = [];
|
|
||||||
|
|
||||||
// const eventHandlers: any[] = [];
|
|
||||||
|
|
||||||
// eventHandlers.push(vosk.onResult(e => {
|
|
||||||
// logger.info('Vosk: result', e.data);
|
|
||||||
// this.voskResult_.push(e.data);
|
|
||||||
// }));
|
|
||||||
|
|
||||||
// eventHandlers.push(vosk.onError(e => {
|
|
||||||
// logger.warn('Vosk: error', e.data);
|
|
||||||
// }));
|
|
||||||
|
|
||||||
// eventHandlers.push(vosk.onTimeout(e => {
|
|
||||||
// logger.warn('Vosk: timeout', e.data);
|
|
||||||
// }));
|
|
||||||
|
|
||||||
// eventHandlers.push(vosk.onFinalResult(e => {
|
|
||||||
// logger.info('Vosk: final result', e.data);
|
|
||||||
// }));
|
|
||||||
|
|
||||||
// logger.info('Vosk: Starting recording...');
|
|
||||||
|
|
||||||
// void vosk.start();
|
|
||||||
|
|
||||||
// const buttonId = await dialogs.pop(this, 'Voice recording in progress...', [
|
|
||||||
// { text: 'Stop recording', id: 'stop' },
|
|
||||||
// { text: _('Cancel'), id: 'cancel' },
|
|
||||||
// ]);
|
|
||||||
|
|
||||||
// logger.info('Vosk: Stopping recording...');
|
|
||||||
// vosk.stop();
|
|
||||||
|
|
||||||
// for (const eventHandler of eventHandlers) {
|
|
||||||
// eventHandler.remove();
|
|
||||||
// }
|
|
||||||
|
|
||||||
// logger.info('Vosk: Recording stopped:', this.voskResult_);
|
|
||||||
|
|
||||||
// if (buttonId === 'cancel') return;
|
|
||||||
|
|
||||||
// const newNote: NoteEntity = { ...this.state.note };
|
|
||||||
// newNote.body = `${newNote.body} ${this.voskResult_.join(' ')}`;
|
|
||||||
// this.setState({ note: newNote });
|
|
||||||
// this.scheduleSave();
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public menuOptions() {
|
public menuOptions() {
|
||||||
const note = this.state.note;
|
const note = this.state.note;
|
||||||
const isTodo = note && !!note.is_todo;
|
const isTodo = note && !!note.is_todo;
|
||||||
|
@ -1726,6 +1676,15 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||||
placeholder={_('Add title')}
|
placeholder={_('Add title')}
|
||||||
placeholderTextColor={theme.colorFaded}
|
placeholderTextColor={theme.colorFaded}
|
||||||
editable={!this.state.readOnly}
|
editable={!this.state.readOnly}
|
||||||
|
multiline={this.state.multiline}
|
||||||
|
submitBehavior = "blurAndSubmit"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
iconName={(!this.state.multiline && 'material menu-down') || (this.state.multiline && 'material menu-up')}
|
||||||
|
onPress={() => this.setState({ multiline: !this.state.multiline })}
|
||||||
|
description={(!this.state.multiline && _('Expand title')) || (this.state.multiline && _('Collapse title'))}
|
||||||
|
iconStyle={this.styles().titleToggleIcon}
|
||||||
|
themeId={this.props.themeId}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { View, StyleSheet, SafeAreaView, ScrollView } from 'react-native';
|
import { View, StyleSheet, TextInput } from 'react-native';
|
||||||
import { AppState } from '../../utils/types';
|
import { AppState } from '../../utils/types';
|
||||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||||
import Revision from '@joplin/lib/models/Revision';
|
import Revision from '@joplin/lib/models/Revision';
|
||||||
|
@ -102,15 +102,12 @@ const useStyles = (themeId: number) => {
|
||||||
root: {
|
root: {
|
||||||
...theme.rootStyle,
|
...theme.rootStyle,
|
||||||
},
|
},
|
||||||
titleContainer: {
|
titleViewContainer: {
|
||||||
paddingLeft: theme.marginLeft,
|
paddingLeft: theme.marginLeft,
|
||||||
paddingRight: theme.marginRight,
|
|
||||||
borderTopColor: theme.dividerColor,
|
borderTopColor: theme.dividerColor,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderBottomColor: theme.dividerColor,
|
borderBottomColor: theme.dividerColor,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
|
||||||
titleViewContainer: {
|
|
||||||
flex: 0,
|
flex: 0,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexBasis: 'auto',
|
flexBasis: 'auto',
|
||||||
|
@ -139,6 +136,7 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
||||||
const { note, resources } = useRevisionNote(revisions, currentRevisionId);
|
const { note, resources } = useRevisionNote(revisions, currentRevisionId);
|
||||||
const [initialScroll, setInitialScroll] = useState(0);
|
const [initialScroll, setInitialScroll] = useState(0);
|
||||||
const [hasRevisions, setHasRevisions] = useState(false);
|
const [hasRevisions, setHasRevisions] = useState(false);
|
||||||
|
const [multiline, setMultiline] = useState(false);
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const result = [];
|
const result = [];
|
||||||
|
@ -201,6 +199,9 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
||||||
const onHelpPress = useCallback(() => {
|
const onHelpPress = useCallback(() => {
|
||||||
void dialogs.info(helpMessageText);
|
void dialogs.info(helpMessageText);
|
||||||
}, [helpMessageText, dialogs]);
|
}, [helpMessageText, dialogs]);
|
||||||
|
const onToggleTitlePress = useCallback(() => {
|
||||||
|
void setMultiline(!multiline);
|
||||||
|
}, [multiline]);
|
||||||
|
|
||||||
const styles = useStyles(props.themeId);
|
const styles = useStyles(props.themeId);
|
||||||
const dropdownLabelText = _('Revision:');
|
const dropdownLabelText = _('Revision:');
|
||||||
|
@ -213,13 +214,20 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const titleComponent = (
|
const titleComponent = (
|
||||||
<SafeAreaView style={styles.titleContainer}>
|
<View style={styles.titleViewContainer}>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<TextInput
|
||||||
<View style={styles.titleViewContainer}>
|
style={styles.titleText}
|
||||||
<Text style={styles.titleText}>{note?.title ?? ''}</Text>
|
value={note?.title ?? ''}
|
||||||
</View>
|
editable={false}
|
||||||
</ScrollView>
|
multiline={multiline}
|
||||||
</SafeAreaView>
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={(!multiline && 'menu-down') || (multiline && 'menu-up')}
|
||||||
|
accessibilityLabel={(!multiline && _('Expand title')) || (multiline && _('Collapse title'))}
|
||||||
|
onPress={onToggleTitlePress}
|
||||||
|
size={30}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return <View style={styles.root}>
|
return <View style={styles.root}>
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { TagEntity } from '@joplin/lib/services/database/types';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||||
|
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dispatch: Dispatch;
|
dispatch: Dispatch;
|
||||||
|
@ -46,13 +47,17 @@ const useStyles = (themeId: number) => {
|
||||||
const TagsScreenComponent: React.FC<Props> = props => {
|
const TagsScreenComponent: React.FC<Props> = props => {
|
||||||
const [tags, setTags] = useState<TagEntity[]>([]);
|
const [tags, setTags] = useState<TagEntity[]>([]);
|
||||||
const styles = useStyles(props.themeId);
|
const styles = useStyles(props.themeId);
|
||||||
|
const collatorLocale = getCollatorLocale();
|
||||||
|
const collator = useMemo(() => {
|
||||||
|
return getCollator(collatorLocale);
|
||||||
|
}, [collatorLocale]);
|
||||||
|
|
||||||
type TagItemPressEvent = { id: string };
|
type TagItemPressEvent = { id: string };
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
useAsyncEffect(async () => {
|
||||||
const tags = await Tag.allWithNotes();
|
const tags = await Tag.allWithNotes();
|
||||||
tags.sort((a, b) => {
|
tags.sort((a, b) => {
|
||||||
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
|
return collator.compare(a.title, b.title);
|
||||||
});
|
});
|
||||||
setTags(tags);
|
setTags(tags);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
@ -5,9 +5,6 @@ import { _, languageName } from '@joplin/lib/locale';
|
||||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||||
import VoiceTyping, { OnTextCallback, VoiceTypingSession } from '../../services/voiceTyping/VoiceTyping';
|
import VoiceTyping, { OnTextCallback, VoiceTypingSession } from '../../services/voiceTyping/VoiceTyping';
|
||||||
import whisper from '../../services/voiceTyping/whisper';
|
import whisper from '../../services/voiceTyping/whisper';
|
||||||
import vosk from '../../services/voiceTyping/vosk';
|
|
||||||
import { AppState } from '../../utils/types';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { RecorderState } from './types';
|
import { RecorderState } from './types';
|
||||||
import RecordingControls from './RecordingControls';
|
import RecordingControls from './RecordingControls';
|
||||||
import { PrimaryButton } from '../buttons';
|
import { PrimaryButton } from '../buttons';
|
||||||
|
@ -16,19 +13,17 @@ import shim from '@joplin/lib/shim';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: string;
|
locale: string;
|
||||||
provider: string;
|
|
||||||
onDismiss: ()=> void;
|
onDismiss: ()=> void;
|
||||||
onText: (text: string)=> void;
|
onText: (text: string)=> void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseVoiceTypingProps {
|
interface UseVoiceTypingProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
provider: string;
|
|
||||||
onSetPreview: OnTextCallback;
|
onSetPreview: OnTextCallback;
|
||||||
onText: OnTextCallback;
|
onText: OnTextCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypingProps) => {
|
const useVoiceTyping = ({ locale, onSetPreview, onText }: UseVoiceTypingProps) => {
|
||||||
const [voiceTyping, setVoiceTyping] = useState<VoiceTypingSession>(null);
|
const [voiceTyping, setVoiceTyping] = useState<VoiceTypingSession>(null);
|
||||||
const [error, setError] = useState<Error|null>(null);
|
const [error, setError] = useState<Error|null>(null);
|
||||||
const [mustDownloadModel, setMustDownloadModel] = useState<boolean | null>(null);
|
const [mustDownloadModel, setMustDownloadModel] = useState<boolean | null>(null);
|
||||||
|
@ -43,8 +38,8 @@ const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypi
|
||||||
voiceTypingRef.current = voiceTyping;
|
voiceTypingRef.current = voiceTyping;
|
||||||
|
|
||||||
const builder = useMemo(() => {
|
const builder = useMemo(() => {
|
||||||
return new VoiceTyping(locale, provider?.startsWith('whisper') ? [whisper] : [vosk]);
|
return new VoiceTyping(locale, [whisper]);
|
||||||
}, [locale, provider]);
|
}, [locale]);
|
||||||
|
|
||||||
const [redownloadCounter, setRedownloadCounter] = useState(0);
|
const [redownloadCounter, setRedownloadCounter] = useState(0);
|
||||||
|
|
||||||
|
@ -121,7 +116,6 @@ const SpeechToTextComponent: React.FC<Props> = props => {
|
||||||
locale: props.locale,
|
locale: props.locale,
|
||||||
onSetPreview: setPreview,
|
onSetPreview: setPreview,
|
||||||
onText: props.onText,
|
onText: props.onText,
|
||||||
provider: props.provider,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -209,6 +203,4 @@ const SpeechToTextComponent: React.FC<Props> = props => {
|
||||||
/>;
|
/>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect((state: AppState) => ({
|
export default SpeechToTextComponent;
|
||||||
provider: state.settings['voiceTyping.preferredProvider'],
|
|
||||||
}))(SpeechToTextComponent);
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { EditorProcessApi, EditorProps, EditorWithParentProps, ExportedWebViewGl
|
||||||
import readFileToBase64 from '../utils/readFileToBase64';
|
import readFileToBase64 from '../utils/readFileToBase64';
|
||||||
import { EditorControl } from '@joplin/editor/types';
|
import { EditorControl } from '@joplin/editor/types';
|
||||||
import { EditorEventType } from '@joplin/editor/events';
|
import { EditorEventType } from '@joplin/editor/events';
|
||||||
|
import InMemoryCache from '@joplin/renderer/InMemoryCache';
|
||||||
|
|
||||||
export { default as setUpLogger } from '../utils/setUpLogger';
|
export { default as setUpLogger } from '../utils/setUpLogger';
|
||||||
|
|
||||||
|
@ -47,6 +48,10 @@ export const createEditorWithParent = ({
|
||||||
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementOrClassName)})`);
|
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementOrClassName)})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveImageSrc can be called frequently for the same image. To avoid unnecessary IPC,
|
||||||
|
// use an InMemoryCache.
|
||||||
|
const resolvedImageSrcCache = new InMemoryCache();
|
||||||
|
|
||||||
const control = createEditor(parentElement, {
|
const control = createEditor(parentElement, {
|
||||||
initialText,
|
initialText,
|
||||||
initialNoteId,
|
initialNoteId,
|
||||||
|
@ -68,8 +73,16 @@ export const createEditorWithParent = ({
|
||||||
allEditors = allEditors.filter(other => other !== control);
|
allEditors = allEditors.filter(other => other !== control);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
resolveImageSrc: (src) => {
|
resolveImageSrc: async (src, reloadCounter) => {
|
||||||
return messenger.remoteApi.onResolveImageSrc(src);
|
const cacheKey = `cachedImage.${reloadCounter}.${src}`;
|
||||||
|
const cachedValue = resolvedImageSrcCache.value(cacheKey);
|
||||||
|
if (cachedValue) {
|
||||||
|
return cachedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = messenger.remoteApi.onResolveImageSrc(src, reloadCounter);
|
||||||
|
resolvedImageSrcCache.setValue(cacheKey, result);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -37,5 +37,5 @@ export interface MainProcessApi {
|
||||||
onEditorAdded(): Promise<void>;
|
onEditorAdded(): Promise<void>;
|
||||||
logMessage(message: string): Promise<void>;
|
logMessage(message: string): Promise<void>;
|
||||||
onPasteFile(type: string, dataBase64: string): Promise<void>;
|
onPasteFile(type: string, dataBase64: string): Promise<void>;
|
||||||
onResolveImageSrc(src: string): Promise<string|null>;
|
onResolveImageSrc(src: string, reloadCounter: number): Promise<string|null>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -130,7 +130,7 @@ const useWebViewSetup = ({
|
||||||
async onEditorAdded() {
|
async onEditorAdded() {
|
||||||
messenger.remoteApi.updatePlugins(codeMirrorPluginsRef.current);
|
messenger.remoteApi.updatePlugins(codeMirrorPluginsRef.current);
|
||||||
},
|
},
|
||||||
async onResolveImageSrc(src) {
|
async onResolveImageSrc(src, reloadCounter) {
|
||||||
const url = parseResourceUrl(src);
|
const url = parseResourceUrl(src);
|
||||||
if (!url.itemId) return null;
|
if (!url.itemId) return null;
|
||||||
const item = await Resource.load(url.itemId);
|
const item = await Resource.load(url.itemId);
|
||||||
|
@ -144,7 +144,8 @@ const useWebViewSetup = ({
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
return Resource.fullPath(item);
|
const path = Resource.fullPath(item);
|
||||||
|
return reloadCounter ? `${path}?r=${reloadCounter}` : path;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -44,11 +44,14 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||||
onAttachRef.current = props.onAttachFile;
|
onAttachRef.current = props.onAttachFile;
|
||||||
|
|
||||||
const markupRenderingSettings = useRef<RenderOptions>(null);
|
const markupRenderingSettings = useRef<RenderOptions>(null);
|
||||||
|
const baseTheme = props.settings.themeData;
|
||||||
markupRenderingSettings.current = {
|
markupRenderingSettings.current = {
|
||||||
themeId: props.themeId,
|
themeId: props.themeId,
|
||||||
highlightedKeywords: [],
|
highlightedKeywords: [],
|
||||||
resources: props.noteResources,
|
resources: props.noteResources,
|
||||||
themeOverrides: {},
|
themeOverrides: {
|
||||||
|
noteViewerFontSize: `${baseTheme.fontSize}${baseTheme.fontSizeUnits ?? 'px'}`,
|
||||||
|
},
|
||||||
noteHash: '',
|
noteHash: '',
|
||||||
initialScroll: 0,
|
initialScroll: 0,
|
||||||
pluginAssetContainerSelector: null,
|
pluginAssetContainerSelector: null,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import './utils/polyfills';
|
||||||
import { AppRegistry } from 'react-native';
|
import { AppRegistry } from 'react-native';
|
||||||
import Root from './root';
|
import Root from './root';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
|
|
|
@ -535,7 +535,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 144;
|
CURRENT_PROJECT_VERSION = 145;
|
||||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Joplin/Info.plist;
|
INFOPLIST_FILE = Joplin/Info.plist;
|
||||||
|
@ -544,7 +544,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 13.4.2;
|
MARKETING_VERSION = 13.4.3;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
|
@ -570,7 +570,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 144;
|
CURRENT_PROJECT_VERSION = 145;
|
||||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||||
INFOPLIST_FILE = Joplin/Info.plist;
|
INFOPLIST_FILE = Joplin/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||||
|
@ -578,7 +578,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 13.4.2;
|
MARKETING_VERSION = 13.4.3;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
|
@ -771,7 +771,7 @@
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 144;
|
CURRENT_PROJECT_VERSION = 145;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
@ -782,7 +782,7 @@
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 13.4.2;
|
MARKETING_VERSION = 13.4.3;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
|
@ -814,7 +814,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 144;
|
CURRENT_PROJECT_VERSION = 145;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
@ -825,7 +825,7 @@
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 13.4.2;
|
MARKETING_VERSION = 13.4.3;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|
|
@ -58,13 +58,13 @@
|
||||||
"react-native-file-viewer": "2.1.5",
|
"react-native-file-viewer": "2.1.5",
|
||||||
"react-native-fs": "2.20.0",
|
"react-native-fs": "2.20.0",
|
||||||
"react-native-get-random-values": "1.11.0",
|
"react-native-get-random-values": "1.11.0",
|
||||||
"react-native-image-picker": "8.0.0",
|
"react-native-image-picker": "8.2.1",
|
||||||
"react-native-localize": "3.4.1",
|
"react-native-localize": "3.4.1",
|
||||||
"react-native-modal-datetime-picker": "18.0.0",
|
"react-native-modal-datetime-picker": "18.0.0",
|
||||||
"react-native-paper": "5.13.5",
|
"react-native-paper": "5.13.5",
|
||||||
"react-native-popup-menu": "0.17.0",
|
"react-native-popup-menu": "0.17.0",
|
||||||
"react-native-quick-actions": "0.3.13",
|
"react-native-quick-actions": "0.3.13",
|
||||||
"react-native-quick-crypto": "0.7.13",
|
"react-native-quick-crypto": "0.7.17",
|
||||||
"react-native-rsa-native": "2.0.5",
|
"react-native-rsa-native": "2.0.5",
|
||||||
"react-native-safe-area-context": "5.4.1",
|
"react-native-safe-area-context": "5.4.1",
|
||||||
"react-native-securerandom": "1.0.1",
|
"react-native-securerandom": "1.0.1",
|
||||||
|
@ -73,9 +73,8 @@
|
||||||
"react-native-url-polyfill": "2.0.0",
|
"react-native-url-polyfill": "2.0.0",
|
||||||
"react-native-vector-icons": "10.2.0",
|
"react-native-vector-icons": "10.2.0",
|
||||||
"react-native-version-info": "1.1.1",
|
"react-native-version-info": "1.1.1",
|
||||||
"react-native-vosk": "0.1.12",
|
|
||||||
"react-native-webview": "13.13.5",
|
"react-native-webview": "13.13.5",
|
||||||
"react-native-zip-archive": "7.0.1",
|
"react-native-zip-archive": "7.0.2",
|
||||||
"react-redux": "8.1.3",
|
"react-redux": "8.1.3",
|
||||||
"redux": "4.2.1",
|
"redux": "4.2.1",
|
||||||
"rn-fetch-blob": "0.12.0",
|
"rn-fetch-blob": "0.12.0",
|
||||||
|
@ -94,35 +93,35 @@
|
||||||
"@joplin/tools": "~3.4",
|
"@joplin/tools": "~3.4",
|
||||||
"@joplin/turndown": "~4.0.80",
|
"@joplin/turndown": "~4.0.80",
|
||||||
"@joplin/turndown-plugin-gfm": "~1.0.62",
|
"@joplin/turndown-plugin-gfm": "~1.0.62",
|
||||||
"@js-draw/material-icons": "1.30.0",
|
"@js-draw/material-icons": "1.30.1",
|
||||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||||
"@react-native-community/cli": "16.0.3",
|
"@react-native-community/cli": "16.0.3",
|
||||||
"@react-native-community/cli-platform-android": "16.0.3",
|
"@react-native-community/cli-platform-android": "16.0.3",
|
||||||
"@react-native-community/cli-platform-ios": "16.0.3",
|
"@react-native-community/cli-platform-ios": "16.0.3",
|
||||||
"@react-native/babel-preset": "0.79.2",
|
"@react-native/babel-preset": "0.79.3",
|
||||||
"@react-native/metro-config": "0.79.2",
|
"@react-native/metro-config": "0.79.3",
|
||||||
"@react-native/typescript-config": "0.79.2",
|
"@react-native/typescript-config": "0.79.2",
|
||||||
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
|
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
|
||||||
"@testing-library/react-native": "13.2.0",
|
"@testing-library/react-native": "13.2.0",
|
||||||
"@types/fs-extra": "11.0.4",
|
"@types/fs-extra": "11.0.4",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/node": "18.19.103",
|
"@types/node": "18.19.112",
|
||||||
"@types/react": "19.0.14",
|
"@types/react": "19.0.14",
|
||||||
"@types/react-redux": "7.1.33",
|
"@types/react-redux": "7.1.33",
|
||||||
"@types/serviceworker": "0.0.135",
|
"@types/serviceworker": "0.0.139",
|
||||||
"@types/tar-stream": "3.1.3",
|
"@types/tar-stream": "3.1.4",
|
||||||
"babel-jest": "29.7.0",
|
"babel-jest": "29.7.0",
|
||||||
"babel-loader": "9.1.3",
|
"babel-loader": "9.1.3",
|
||||||
"babel-plugin-module-resolver": "4.1.0",
|
"babel-plugin-module-resolver": "4.1.0",
|
||||||
"babel-plugin-react-native-web": "0.20.0",
|
"babel-plugin-react-native-web": "0.20.0",
|
||||||
"esbuild": "0.25.4",
|
"esbuild": "0.25.5",
|
||||||
"fast-deep-equal": "3.1.3",
|
"fast-deep-equal": "3.1.3",
|
||||||
"fs-extra": "11.2.0",
|
"fs-extra": "11.2.0",
|
||||||
"gulp": "4.0.2",
|
"gulp": "4.0.2",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-environment-jsdom": "29.7.0",
|
"jest-environment-jsdom": "29.7.0",
|
||||||
"jetifier": "2.0.0",
|
"jetifier": "2.0.0",
|
||||||
"js-draw": "1.30.0",
|
"js-draw": "1.30.1",
|
||||||
"jsdom": "26.1.0",
|
"jsdom": "26.1.0",
|
||||||
"nodemon": "3.1.10",
|
"nodemon": "3.1.10",
|
||||||
"punycode": "2.3.1",
|
"punycode": "2.3.1",
|
||||||
|
|
|
@ -14,7 +14,7 @@ import NoteScreen from './components/screens/Note/Note';
|
||||||
import UpgradeSyncTargetScreen from './components/screens/UpgradeSyncTargetScreen';
|
import UpgradeSyncTargetScreen from './components/screens/UpgradeSyncTargetScreen';
|
||||||
import Setting, { } from '@joplin/lib/models/Setting';
|
import Setting, { } from '@joplin/lib/models/Setting';
|
||||||
import PoorManIntervals from '@joplin/lib/PoorManIntervals';
|
import PoorManIntervals from '@joplin/lib/PoorManIntervals';
|
||||||
import reducer, { NotesParent, serializeNotesParent } from '@joplin/lib/reducer';
|
import { NotesParent, serializeNotesParent } from '@joplin/lib/reducer';
|
||||||
import ShareExtension, { UnsubscribeShareListener } from './utils/ShareExtension';
|
import ShareExtension, { UnsubscribeShareListener } from './utils/ShareExtension';
|
||||||
import handleShared from './utils/shareHandler';
|
import handleShared from './utils/shareHandler';
|
||||||
import { _, setLocale } from '@joplin/lib/locale';
|
import { _, setLocale } from '@joplin/lib/locale';
|
||||||
|
@ -28,7 +28,6 @@ import NetInfo, { NetInfoSubscription } from '@react-native-community/netinfo';
|
||||||
const DropdownAlert = require('react-native-dropdownalert').default;
|
const DropdownAlert = require('react-native-dropdownalert').default;
|
||||||
import SafeAreaView from './components/SafeAreaView';
|
import SafeAreaView from './components/SafeAreaView';
|
||||||
const { connect, Provider } = require('react-redux');
|
const { connect, Provider } = require('react-redux');
|
||||||
import fastDeepEqual = require('fast-deep-equal');
|
|
||||||
import { Provider as PaperProvider, MD3DarkTheme, MD3LightTheme } from 'react-native-paper';
|
import { Provider as PaperProvider, MD3DarkTheme, MD3LightTheme } from 'react-native-paper';
|
||||||
import BackButtonService, { BackButtonHandler } from './services/BackButtonService';
|
import BackButtonService, { BackButtonHandler } from './services/BackButtonService';
|
||||||
import NavService from '@joplin/lib/services/NavService';
|
import NavService from '@joplin/lib/services/NavService';
|
||||||
|
@ -95,7 +94,6 @@ import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTh
|
||||||
import PluginRunnerWebView from './components/plugins/PluginRunnerWebView';
|
import PluginRunnerWebView from './components/plugins/PluginRunnerWebView';
|
||||||
import { refreshFolders, scheduleRefreshFolders } from '@joplin/lib/folders-screen-utils';
|
import { refreshFolders, scheduleRefreshFolders } from '@joplin/lib/folders-screen-utils';
|
||||||
import ShareManager from './components/screens/ShareManager';
|
import ShareManager from './components/screens/ShareManager';
|
||||||
import appDefaultState from './utils/appDefaultState';
|
|
||||||
import { setDateFormat, setTimeFormat, setTimeLocale } from '@joplin/utils/time';
|
import { setDateFormat, setTimeFormat, setTimeLocale } from '@joplin/utils/time';
|
||||||
import DialogManager from './components/DialogManager';
|
import DialogManager from './components/DialogManager';
|
||||||
import { AppState } from './utils/types';
|
import { AppState } from './utils/types';
|
||||||
|
@ -108,6 +106,7 @@ import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
|
||||||
import DocumentScanner from './components/screens/DocumentScanner/DocumentScanner';
|
import DocumentScanner from './components/screens/DocumentScanner/DocumentScanner';
|
||||||
import buildStartupTasks from './utils/buildStartupTasks';
|
import buildStartupTasks from './utils/buildStartupTasks';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import appReducer from './utils/appReducer';
|
||||||
|
|
||||||
const logger = Logger.create('root');
|
const logger = Logger.create('root');
|
||||||
const perfLogger = PerformanceLogger.create();
|
const perfLogger = PerformanceLogger.create();
|
||||||
|
@ -235,204 +234,6 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
||||||
const navHistory: any[] = [];
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
||||||
function historyCanGoBackTo(route: any) {
|
|
||||||
if (route.routeName === 'Folder') return false;
|
|
||||||
|
|
||||||
// This is an intermediate screen that acts more like a modal -- it should be skipped in the
|
|
||||||
// navigation history.
|
|
||||||
if (route.routeName === 'DocumentScanner') return false;
|
|
||||||
|
|
||||||
// There's no point going back to these screens in general and, at least in OneDrive case,
|
|
||||||
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
|
|
||||||
if (route.routeName === 'OneDriveLogin') return false;
|
|
||||||
if (route.routeName === 'DropboxLogin') return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
||||||
const appReducer = (state = appDefaultState, action: any) => {
|
|
||||||
let newState = state;
|
|
||||||
let historyGoingBack = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (action.type) {
|
|
||||||
|
|
||||||
case 'NAV_BACK':
|
|
||||||
case 'NAV_GO':
|
|
||||||
|
|
||||||
if (action.type === 'NAV_BACK') {
|
|
||||||
if (!navHistory.length) break;
|
|
||||||
|
|
||||||
const newAction = navHistory.pop();
|
|
||||||
action = newAction ? newAction : navHistory.pop();
|
|
||||||
|
|
||||||
historyGoingBack = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const currentRoute = state.route;
|
|
||||||
|
|
||||||
if (!historyGoingBack && historyCanGoBackTo(currentRoute)) {
|
|
||||||
const previousRoute = navHistory.length && navHistory[navHistory.length - 1];
|
|
||||||
const isDifferentRoute = !previousRoute || !fastDeepEqual(navHistory[navHistory.length - 1], currentRoute);
|
|
||||||
|
|
||||||
// Avoid multiple consecutive duplicate screens in the navigation history -- these can make
|
|
||||||
// pressing "back" seem to have no effect.
|
|
||||||
if (isDifferentRoute) {
|
|
||||||
navHistory.push(currentRoute);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.clearHistory) {
|
|
||||||
navHistory.splice(0, navHistory.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
newState = { ...state };
|
|
||||||
|
|
||||||
newState.selectedNoteHash = '';
|
|
||||||
|
|
||||||
if (action.routeName === 'Search') {
|
|
||||||
newState.notesParentType = 'Search';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('noteId' in action) {
|
|
||||||
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('folderId' in action) {
|
|
||||||
newState.selectedFolderId = action.folderId;
|
|
||||||
newState.notesParentType = 'Folder';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('tagId' in action) {
|
|
||||||
newState.selectedTagId = action.tagId;
|
|
||||||
newState.notesParentType = 'Tag';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('smartFilterId' in action) {
|
|
||||||
newState.smartFilterId = action.smartFilterId;
|
|
||||||
newState.selectedSmartFilterId = action.smartFilterId;
|
|
||||||
newState.notesParentType = 'SmartFilter';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('itemType' in action) {
|
|
||||||
newState.selectedItemType = action.itemType;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('noteHash' in action) {
|
|
||||||
newState.selectedNoteHash = action.noteHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('sharedData' in action) {
|
|
||||||
newState.sharedData = action.sharedData;
|
|
||||||
} else {
|
|
||||||
newState.sharedData = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
newState.route = action;
|
|
||||||
newState.historyCanGoBack = !!navHistory.length;
|
|
||||||
|
|
||||||
logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'SIDE_MENU_TOGGLE':
|
|
||||||
|
|
||||||
newState = { ...state };
|
|
||||||
newState.showSideMenu = !newState.showSideMenu;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'SIDE_MENU_OPEN':
|
|
||||||
|
|
||||||
newState = { ...state };
|
|
||||||
newState.showSideMenu = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'SIDE_MENU_CLOSE':
|
|
||||||
|
|
||||||
newState = { ...state };
|
|
||||||
newState.showSideMenu = false;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE':
|
|
||||||
newState = { ...state };
|
|
||||||
newState.showPanelsDialog = action.visible;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'NOTE_SELECTION_TOGGLE':
|
|
||||||
|
|
||||||
{
|
|
||||||
newState = { ...state };
|
|
||||||
|
|
||||||
const noteId = action.id;
|
|
||||||
const newSelectedNoteIds = state.selectedNoteIds.slice();
|
|
||||||
const existingIndex = state.selectedNoteIds.indexOf(noteId);
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
newSelectedNoteIds.splice(existingIndex, 1);
|
|
||||||
} else {
|
|
||||||
newSelectedNoteIds.push(noteId);
|
|
||||||
}
|
|
||||||
|
|
||||||
newState.selectedNoteIds = newSelectedNoteIds;
|
|
||||||
newState.noteSelectionEnabled = !!newSelectedNoteIds.length;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'NOTE_SELECTION_START':
|
|
||||||
|
|
||||||
if (!state.noteSelectionEnabled) {
|
|
||||||
newState = { ...state };
|
|
||||||
newState.noteSelectionEnabled = true;
|
|
||||||
newState.selectedNoteIds = [action.id];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'NOTE_SELECTION_END':
|
|
||||||
|
|
||||||
newState = { ...state };
|
|
||||||
newState.noteSelectionEnabled = false;
|
|
||||||
newState.selectedNoteIds = [];
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'NOTE_SIDE_MENU_OPTIONS_SET':
|
|
||||||
|
|
||||||
newState = { ...state };
|
|
||||||
newState.noteSideMenuOptions = action.options;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED':
|
|
||||||
newState = { ...state };
|
|
||||||
newState.disableSideMenuGestures = action.disableSideMenuGestures;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'MOBILE_DATA_WARNING_UPDATE':
|
|
||||||
|
|
||||||
newState = { ...state };
|
|
||||||
newState.isOnMobileData = action.isOnMobileData;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'KEYBOARD_VISIBLE_CHANGE':
|
|
||||||
newState = { ...state, keyboardVisible: action.visible };
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'NOTE_EDITOR_VISIBLE_CHANGE':
|
|
||||||
newState = { ...state, noteEditorVisible: action.visible };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return reducer(newState, action) as AppState;
|
|
||||||
};
|
|
||||||
|
|
||||||
const store = createStore(appReducer, applyMiddleware(generalMiddleware));
|
const store = createStore(appReducer, applyMiddleware(generalMiddleware));
|
||||||
storeDispatch = store.dispatch;
|
storeDispatch = store.dispatch;
|
||||||
|
|
||||||
|
@ -975,46 +776,46 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||||
// Wrap everything in a PaperProvider -- this allows using components from react-native-paper
|
// Wrap everything in a PaperProvider -- this allows using components from react-native-paper
|
||||||
return (
|
return (
|
||||||
<FocusControl.Provider>
|
<FocusControl.Provider>
|
||||||
<PaperProvider theme={{
|
<MenuProvider
|
||||||
...paperTheme,
|
style={{ flex: 1 }}
|
||||||
version: 3,
|
closeButtonLabel={_('Dismiss')}
|
||||||
colors: {
|
>
|
||||||
...paperTheme.colors,
|
<PaperProvider theme={{
|
||||||
onPrimaryContainer: theme.color5,
|
...paperTheme,
|
||||||
primaryContainer: theme.backgroundColor5,
|
version: 3,
|
||||||
|
colors: {
|
||||||
|
...paperTheme.colors,
|
||||||
|
onPrimaryContainer: theme.color5,
|
||||||
|
primaryContainer: theme.backgroundColor5,
|
||||||
|
|
||||||
outline: theme.codeBorderColor,
|
outline: theme.codeBorderColor,
|
||||||
|
|
||||||
primary: theme.color4,
|
primary: theme.color4,
|
||||||
onPrimary: theme.backgroundColor4,
|
onPrimary: theme.backgroundColor4,
|
||||||
|
|
||||||
background: theme.backgroundColor,
|
background: theme.backgroundColor,
|
||||||
|
|
||||||
surface: theme.backgroundColor,
|
surface: theme.backgroundColor,
|
||||||
onSurface: theme.color,
|
onSurface: theme.color,
|
||||||
|
|
||||||
secondaryContainer: theme.raisedBackgroundColor,
|
secondaryContainer: theme.raisedBackgroundColor,
|
||||||
onSecondaryContainer: theme.raisedColor,
|
onSecondaryContainer: theme.raisedColor,
|
||||||
|
|
||||||
surfaceVariant: theme.backgroundColor3,
|
surfaceVariant: theme.backgroundColor3,
|
||||||
onSurfaceVariant: theme.color3,
|
onSurfaceVariant: theme.color3,
|
||||||
|
|
||||||
elevation: {
|
elevation: {
|
||||||
level0: 'transparent',
|
level0: 'transparent',
|
||||||
level1: theme.oddBackgroundColor,
|
level1: theme.oddBackgroundColor,
|
||||||
level2: theme.raisedBackgroundColor,
|
level2: theme.raisedBackgroundColor,
|
||||||
level3: theme.raisedBackgroundColor,
|
level3: theme.raisedBackgroundColor,
|
||||||
level4: theme.raisedBackgroundColor,
|
level4: theme.raisedBackgroundColor,
|
||||||
level5: theme.raisedBackgroundColor,
|
level5: theme.raisedBackgroundColor,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}}>
|
||||||
}}>
|
<DialogManager themeId={this.props.themeId}>
|
||||||
<DialogManager themeId={this.props.themeId}>
|
<StatusBar barStyle={statusBarStyle} />
|
||||||
<StatusBar barStyle={statusBarStyle} />
|
|
||||||
<MenuProvider
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
closeButtonLabel={_('Dismiss')}
|
|
||||||
>
|
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<FocusControl.MainAppContent style={{ flex: 1 }}>
|
<FocusControl.MainAppContent style={{ flex: 1 }}>
|
||||||
{shouldShowMainContent ? mainContent : (
|
{shouldShowMainContent ? mainContent : (
|
||||||
|
@ -1028,9 +829,9 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||||
)}
|
)}
|
||||||
</FocusControl.MainAppContent>
|
</FocusControl.MainAppContent>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</MenuProvider>
|
</DialogManager>
|
||||||
</DialogManager>
|
</PaperProvider>
|
||||||
</PaperProvider>
|
</MenuProvider>
|
||||||
</FocusControl.Provider>
|
</FocusControl.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,46 +1,43 @@
|
||||||
import { RSA } from '@joplin/lib/services/e2ee/types';
|
import buildRsaCryptoProvider from '@joplin/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider';
|
||||||
import shim from '@joplin/lib/shim';
|
import { WebCryptoSlice } from '@joplin/lib/services/e2ee/ppk/webCrypto/WebCryptoRsa';
|
||||||
import Logger from '@joplin/utils/Logger';
|
import { CiphertextBuffer, PublicKeyAlgorithm, PublicKeyCrypto, PublicKeyCryptoProvider } from '@joplin/lib/services/e2ee/types';
|
||||||
|
import QuickCrypto from 'react-native-quick-crypto';
|
||||||
const RnRSA = require('react-native-rsa-native').RSA;
|
const RnRSA = require('react-native-rsa-native').RSA;
|
||||||
|
|
||||||
interface RSAKeyPair {
|
interface LegacyRsaKeyPair {
|
||||||
public: string;
|
public: string;
|
||||||
private: string;
|
private: string;
|
||||||
keySizeBits: number;
|
keySizeBits: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = Logger.create('RSA');
|
const legacyRsa: PublicKeyCrypto = {
|
||||||
|
generateKeyPair: async () => {
|
||||||
const rsa: RSA = {
|
const keySize = 2048;
|
||||||
|
const keys: LegacyRsaKeyPair = await RnRSA.generateKeys(keySize);
|
||||||
generateKeyPair: async (keySize: number): Promise<RSAKeyPair> => {
|
|
||||||
if (shim.mobilePlatform() === 'web') {
|
|
||||||
// TODO: Try to implement with SubtleCrypto. May require migrating the RSA algorithm used on
|
|
||||||
// desktop and mobile (which is not supported on web). See commit 12adcd9dbc3f723bac36ff4447701573084c4694.
|
|
||||||
logger.warn('RSA on web is not yet supported.');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys: RSAKeyPair = await RnRSA.generateKeys(keySize);
|
|
||||||
|
|
||||||
// Sanity check
|
// Sanity check
|
||||||
if (!keys.private) throw new Error('No private key was generated');
|
if (!keys.private) throw new Error('No private key was generated');
|
||||||
if (!keys.public) throw new Error('No public key was generated');
|
if (!keys.public) throw new Error('No public key was generated');
|
||||||
|
|
||||||
return rsa.loadKeys(keys.public, keys.private, keySize);
|
const keyPair = await legacyRsa.loadKeys(keys.public, keys.private, keySize);
|
||||||
|
return {
|
||||||
|
keyPair,
|
||||||
|
keySize,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
loadKeys: async (publicKey: string, privateKey: string, keySizeBits: number): Promise<RSAKeyPair> => {
|
maximumPlaintextLengthBytes: 190,
|
||||||
|
|
||||||
|
loadKeys: async (publicKey: string, privateKey: string, keySizeBits: number): Promise<LegacyRsaKeyPair> => {
|
||||||
return { public: publicKey, private: privateKey, keySizeBits };
|
return { public: publicKey, private: privateKey, keySizeBits };
|
||||||
},
|
},
|
||||||
|
|
||||||
encrypt: async (plaintextUtf8: string, rsaKeyPair: RSAKeyPair): Promise<string> => {
|
encrypt: async (plaintextUtf8: string, rsaKeyPair: LegacyRsaKeyPair) => {
|
||||||
// TODO: Support long-data encryption in a way compatible with node-rsa.
|
// TODO: Support long-data encryption in a way compatible with node-rsa.
|
||||||
return RnRSA.encrypt(plaintextUtf8, rsaKeyPair.public);
|
return Buffer.from(await RnRSA.encrypt(plaintextUtf8, rsaKeyPair.public), 'base64');
|
||||||
},
|
},
|
||||||
|
|
||||||
decrypt: async (ciphertextBase64: string, rsaKeyPair: RSAKeyPair): Promise<string> => {
|
decrypt: async (ciphertextBuffer: CiphertextBuffer, rsaKeyPair: LegacyRsaKeyPair): Promise<string> => {
|
||||||
const ciphertextBuffer = Buffer.from(ciphertextBase64, 'base64');
|
|
||||||
const maximumEncryptedSize = Math.floor(rsaKeyPair.keySizeBits / 8); // Usually 256
|
const maximumEncryptedSize = Math.floor(rsaKeyPair.keySizeBits / 8); // Usually 256
|
||||||
|
|
||||||
// On iOS, .decrypt fails without throwing or rejecting.
|
// On iOS, .decrypt fails without throwing or rejecting.
|
||||||
|
@ -75,20 +72,26 @@ const rsa: RSA = {
|
||||||
}
|
}
|
||||||
return result.join('');
|
return result.join('');
|
||||||
} else {
|
} else {
|
||||||
const plainText = await RnRSA.decrypt(ciphertextBase64, rsaKeyPair.private);
|
const plainText = await RnRSA.decrypt(ciphertextBuffer.toString('base64'), rsaKeyPair.private);
|
||||||
handleError(plainText);
|
handleError(plainText);
|
||||||
return plainText;
|
return plainText;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
publicKey: (rsaKeyPair: RSAKeyPair): string => {
|
publicKey: async (rsaKeyPair: LegacyRsaKeyPair) => {
|
||||||
return rsaKeyPair.public;
|
return rsaKeyPair.public;
|
||||||
},
|
},
|
||||||
|
|
||||||
privateKey: (rsaKeyPair: RSAKeyPair): string => {
|
privateKey: async (rsaKeyPair: LegacyRsaKeyPair) => {
|
||||||
return rsaKeyPair.private;
|
return rsaKeyPair.private;
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const rsa: PublicKeyCryptoProvider = {
|
||||||
|
[PublicKeyAlgorithm.Unknown]: null,
|
||||||
|
[PublicKeyAlgorithm.RsaV1]: legacyRsa,
|
||||||
|
[PublicKeyAlgorithm.RsaV2]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV2, QuickCrypto as WebCryptoSlice),
|
||||||
|
[PublicKeyAlgorithm.RsaV3]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV3, QuickCrypto as WebCryptoSlice),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default rsa;
|
export default rsa;
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import buildRsaCryptoProvider from '@joplin/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider';
|
||||||
|
import { PublicKeyAlgorithm, PublicKeyCryptoProvider } from '@joplin/lib/services/e2ee/types';
|
||||||
|
|
||||||
|
const rsa: PublicKeyCryptoProvider = {
|
||||||
|
[PublicKeyAlgorithm.Unknown]: null,
|
||||||
|
[PublicKeyAlgorithm.RsaV1]: null, // Unsupported on web
|
||||||
|
[PublicKeyAlgorithm.RsaV2]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV2, crypto),
|
||||||
|
[PublicKeyAlgorithm.RsaV3]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV3, crypto),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default rsa;
|
|
@ -1,197 +0,0 @@
|
||||||
import { languageCodeOnly } from '@joplin/lib/locale';
|
|
||||||
import Logger from '@joplin/utils/Logger';
|
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
|
||||||
import { rtrimSlashes } from '@joplin/lib/path-utils';
|
|
||||||
import shim from '@joplin/lib/shim';
|
|
||||||
import Vosk from 'react-native-vosk';
|
|
||||||
import RNFetchBlob from 'rn-fetch-blob';
|
|
||||||
import { VoiceTypingProvider, VoiceTypingSession } from './VoiceTyping';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
const logger = Logger.create('voiceTyping/vosk');
|
|
||||||
|
|
||||||
enum State {
|
|
||||||
Idle = 0,
|
|
||||||
Recording,
|
|
||||||
Completing,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StartOptions {
|
|
||||||
onResult: (text: string)=> void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let vosk_: Record<string, Vosk> = {};
|
|
||||||
|
|
||||||
let state_: State = State.Idle;
|
|
||||||
|
|
||||||
const defaultSupportedLanguages = {
|
|
||||||
'en': 'https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip',
|
|
||||||
'zh': 'https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip',
|
|
||||||
'ru': 'https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip',
|
|
||||||
'fr': 'https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip',
|
|
||||||
'de': 'https://alphacephei.com/vosk/models/vosk-model-small-de-0.15.zip',
|
|
||||||
'es': 'https://alphacephei.com/vosk/models/vosk-model-small-es-0.42.zip',
|
|
||||||
'pt': 'https://alphacephei.com/vosk/models/vosk-model-small-pt-0.3.zip',
|
|
||||||
'tr': 'https://alphacephei.com/vosk/models/vosk-model-small-tr-0.3.zip',
|
|
||||||
'vn': 'https://alphacephei.com/vosk/models/vosk-model-small-vn-0.4.zip',
|
|
||||||
'it': 'https://alphacephei.com/vosk/models/vosk-model-small-it-0.22.zip',
|
|
||||||
'nl': 'https://alphacephei.com/vosk/models/vosk-model-small-nl-0.22.zip',
|
|
||||||
'uk': 'https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-small.zip',
|
|
||||||
'ja': 'https://alphacephei.com/vosk/models/vosk-model-small-ja-0.22.zip',
|
|
||||||
'hi': 'https://alphacephei.com/vosk/models/vosk-model-small-hi-0.22.zip',
|
|
||||||
'cs': 'https://alphacephei.com/vosk/models/vosk-model-small-cs-0.4-rhasspy.zip',
|
|
||||||
'pl': 'https://alphacephei.com/vosk/models/vosk-model-small-pl-0.22.zip',
|
|
||||||
'uz': 'https://alphacephei.com/vosk/models/vosk-model-small-uz-0.22.zip',
|
|
||||||
'ko': 'https://alphacephei.com/vosk/models/vosk-model-small-ko-0.22.zip',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isSupportedLanguage = (locale: string) => {
|
|
||||||
const l = languageCodeOnly(locale).toLowerCase();
|
|
||||||
return Object.keys(defaultSupportedLanguages).includes(l);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Where all the models files for all the languages are
|
|
||||||
const getModelRootDir = () => {
|
|
||||||
return `${RNFetchBlob.fs.dirs.DocumentDir}/vosk-models`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Where we unzip a model after downloading it
|
|
||||||
const getUnzipDir = (locale: string) => {
|
|
||||||
return `${getModelRootDir()}/${locale}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Where the model for a particular language is
|
|
||||||
const getModelDir = (locale: string) => {
|
|
||||||
return `${getUnzipDir(locale)}/model`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const languageModelUrl = (locale: string): string => {
|
|
||||||
const lang = languageCodeOnly(locale).toLowerCase();
|
|
||||||
if (!(lang in defaultSupportedLanguages)) throw new Error(`No language file for: ${locale}`);
|
|
||||||
|
|
||||||
const urlTemplate = rtrimSlashes(Setting.value('voiceTypingBaseUrl').trim());
|
|
||||||
|
|
||||||
if (urlTemplate) {
|
|
||||||
let url = rtrimSlashes(urlTemplate);
|
|
||||||
if (!url.includes('{lang}')) url += '/{lang}.zip';
|
|
||||||
return url.replace(/\{lang\}/g, lang);
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
||||||
return (defaultSupportedLanguages as any)[lang];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export const getVosk = async (modelDir: string, locale: string) => {
|
|
||||||
if (vosk_[locale]) return vosk_[locale];
|
|
||||||
|
|
||||||
const vosk = new Vosk();
|
|
||||||
logger.info(`Loading model from ${modelDir}`);
|
|
||||||
await shim.fsDriver().readDirStats(modelDir);
|
|
||||||
const result = await vosk.loadModel(modelDir);
|
|
||||||
logger.info('getVosk:', result);
|
|
||||||
|
|
||||||
vosk_ = { [locale]: vosk };
|
|
||||||
|
|
||||||
return vosk;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const startRecording = (vosk: Vosk, options: StartOptions): VoiceTypingSession => {
|
|
||||||
if (state_ !== State.Idle) throw new Error('Vosk is already recording');
|
|
||||||
|
|
||||||
state_ = State.Recording;
|
|
||||||
|
|
||||||
const result: string[] = [];
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
||||||
const eventHandlers: any[] = [];
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
|
||||||
const finalResultPromiseResolve: Function = null;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
|
||||||
const finalResultPromiseReject: Function = null;
|
|
||||||
const finalResultTimeout = false;
|
|
||||||
|
|
||||||
const completeRecording = (finalResult: string, error: Error) => {
|
|
||||||
logger.info(`Complete recording. Final result: ${finalResult}. Error:`, error);
|
|
||||||
|
|
||||||
for (const eventHandler of eventHandlers) {
|
|
||||||
eventHandler.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
vosk.cleanup();
|
|
||||||
|
|
||||||
state_ = State.Idle;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (finalResultPromiseReject) finalResultPromiseReject(error);
|
|
||||||
} else {
|
|
||||||
if (finalResultPromiseResolve) finalResultPromiseResolve(finalResult);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
eventHandlers.push(vosk.onResult(e => {
|
|
||||||
const text = e.data;
|
|
||||||
logger.info('Result', text);
|
|
||||||
result.push(text);
|
|
||||||
options.onResult(text);
|
|
||||||
}));
|
|
||||||
|
|
||||||
eventHandlers.push(vosk.onError(e => {
|
|
||||||
logger.warn('Error', e.data);
|
|
||||||
}));
|
|
||||||
|
|
||||||
eventHandlers.push(vosk.onTimeout(e => {
|
|
||||||
logger.warn('Timeout', e.data);
|
|
||||||
}));
|
|
||||||
|
|
||||||
eventHandlers.push(vosk.onFinalResult(e => {
|
|
||||||
logger.info('Final result', e.data);
|
|
||||||
|
|
||||||
if (finalResultTimeout) {
|
|
||||||
logger.warn('Got final result - but already timed out. Not doing anything.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
completeRecording(e.data, null);
|
|
||||||
}));
|
|
||||||
|
|
||||||
const stopOrCancel = () => {
|
|
||||||
if (state_ === State.Recording) {
|
|
||||||
logger.info('Cancelling...');
|
|
||||||
state_ = State.Completing;
|
|
||||||
vosk.stopOnly();
|
|
||||||
completeRecording('', null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
start: async () => {
|
|
||||||
logger.info('Starting recording...');
|
|
||||||
await vosk.start();
|
|
||||||
},
|
|
||||||
stop: async () => {
|
|
||||||
stopOrCancel();
|
|
||||||
},
|
|
||||||
cancel: async () => {
|
|
||||||
stopOrCancel();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const vosk: VoiceTypingProvider = {
|
|
||||||
supported: () => true,
|
|
||||||
modelLocalFilepath: (locale: string) => getModelDir(locale),
|
|
||||||
deleteCachedModels: async (locale: string) => {
|
|
||||||
const path = getModelDir(locale);
|
|
||||||
await shim.fsDriver().remove(path, { recursive: true });
|
|
||||||
},
|
|
||||||
getDownloadUrl: (locale) => languageModelUrl(locale),
|
|
||||||
getUuidPath: (locale: string) => join(getModelDir(locale), 'uuid'),
|
|
||||||
build: async ({ callbacks, locale, modelPath }) => {
|
|
||||||
const vosk = await getVosk(modelPath, locale);
|
|
||||||
return startRecording(vosk, { onResult: callbacks.onFinalize });
|
|
||||||
},
|
|
||||||
modelName: 'vosk',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default vosk;
|
|
|
@ -1,15 +0,0 @@
|
||||||
import { VoiceTypingProvider } from './VoiceTyping';
|
|
||||||
|
|
||||||
const vosk: VoiceTypingProvider = {
|
|
||||||
supported: () => false,
|
|
||||||
modelLocalFilepath: () => null,
|
|
||||||
getDownloadUrl: () => null,
|
|
||||||
getUuidPath: () => null,
|
|
||||||
deleteCachedModels: () => null,
|
|
||||||
build: async () => {
|
|
||||||
throw new Error('Unsupported!');
|
|
||||||
},
|
|
||||||
modelName: 'vosk',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default vosk;
|
|
|
@ -0,0 +1,207 @@
|
||||||
|
import reducer from '@joplin/lib/reducer';
|
||||||
|
import { AppState } from './types';
|
||||||
|
import appDefaultState from './appDefaultState';
|
||||||
|
import fastDeepEqual = require('fast-deep-equal');
|
||||||
|
import Logger from '@joplin/utils/Logger';
|
||||||
|
|
||||||
|
const logger = Logger.create('appReducer');
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
|
const navHistory: any[] = [];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
|
function historyCanGoBackTo(route: any) {
|
||||||
|
if (route.routeName === 'Folder') return false;
|
||||||
|
|
||||||
|
// This is an intermediate screen that acts more like a modal -- it should be skipped in the
|
||||||
|
// navigation history.
|
||||||
|
if (route.routeName === 'DocumentScanner') return false;
|
||||||
|
|
||||||
|
// There's no point going back to these screens in general and, at least in OneDrive case,
|
||||||
|
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
|
||||||
|
if (route.routeName === 'OneDriveLogin') return false;
|
||||||
|
if (route.routeName === 'DropboxLogin') return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
|
const appReducer = (state = appDefaultState, action: any) => {
|
||||||
|
let newState = state;
|
||||||
|
let historyGoingBack = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action.type) {
|
||||||
|
|
||||||
|
case 'NAV_BACK':
|
||||||
|
case 'NAV_GO':
|
||||||
|
|
||||||
|
if (action.type === 'NAV_BACK') {
|
||||||
|
if (!navHistory.length) break;
|
||||||
|
|
||||||
|
const newAction = navHistory.pop();
|
||||||
|
action = newAction ? newAction : navHistory.pop();
|
||||||
|
|
||||||
|
historyGoingBack = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const currentRoute = state.route;
|
||||||
|
|
||||||
|
if (!historyGoingBack && historyCanGoBackTo(currentRoute)) {
|
||||||
|
const previousRoute = navHistory.length && navHistory[navHistory.length - 1];
|
||||||
|
const isDifferentRoute = !previousRoute || !fastDeepEqual(navHistory[navHistory.length - 1], currentRoute);
|
||||||
|
|
||||||
|
// Avoid multiple consecutive duplicate screens in the navigation history -- these can make
|
||||||
|
// pressing "back" seem to have no effect.
|
||||||
|
if (isDifferentRoute) {
|
||||||
|
navHistory.push(currentRoute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.clearHistory) {
|
||||||
|
navHistory.splice(0, navHistory.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
newState = { ...state };
|
||||||
|
|
||||||
|
newState.selectedNoteHash = '';
|
||||||
|
|
||||||
|
if (action.routeName === 'Search') {
|
||||||
|
newState.notesParentType = 'Search';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('noteId' in action) {
|
||||||
|
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('folderId' in action) {
|
||||||
|
newState.selectedFolderId = action.folderId;
|
||||||
|
newState.notesParentType = 'Folder';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('tagId' in action) {
|
||||||
|
newState.selectedTagId = action.tagId;
|
||||||
|
newState.notesParentType = 'Tag';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('smartFilterId' in action) {
|
||||||
|
newState.smartFilterId = action.smartFilterId;
|
||||||
|
newState.selectedSmartFilterId = action.smartFilterId;
|
||||||
|
newState.notesParentType = 'SmartFilter';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('itemType' in action) {
|
||||||
|
newState.selectedItemType = action.itemType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('noteHash' in action) {
|
||||||
|
newState.selectedNoteHash = action.noteHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('sharedData' in action) {
|
||||||
|
newState.sharedData = action.sharedData;
|
||||||
|
} else {
|
||||||
|
newState.sharedData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
newState.route = action;
|
||||||
|
newState.historyCanGoBack = !!navHistory.length;
|
||||||
|
|
||||||
|
logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SIDE_MENU_TOGGLE':
|
||||||
|
|
||||||
|
newState = { ...state };
|
||||||
|
newState.showSideMenu = !newState.showSideMenu;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SIDE_MENU_OPEN':
|
||||||
|
|
||||||
|
newState = { ...state };
|
||||||
|
newState.showSideMenu = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SIDE_MENU_CLOSE':
|
||||||
|
|
||||||
|
newState = { ...state };
|
||||||
|
newState.showSideMenu = false;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE':
|
||||||
|
newState = { ...state };
|
||||||
|
newState.showPanelsDialog = action.visible;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NOTE_SELECTION_TOGGLE':
|
||||||
|
|
||||||
|
{
|
||||||
|
newState = { ...state };
|
||||||
|
|
||||||
|
const noteId = action.id;
|
||||||
|
const newSelectedNoteIds = state.selectedNoteIds.slice();
|
||||||
|
const existingIndex = state.selectedNoteIds.indexOf(noteId);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
newSelectedNoteIds.splice(existingIndex, 1);
|
||||||
|
} else {
|
||||||
|
newSelectedNoteIds.push(noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
newState.selectedNoteIds = newSelectedNoteIds;
|
||||||
|
newState.noteSelectionEnabled = !!newSelectedNoteIds.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NOTE_SELECTION_START':
|
||||||
|
|
||||||
|
if (!state.noteSelectionEnabled) {
|
||||||
|
newState = { ...state };
|
||||||
|
newState.noteSelectionEnabled = true;
|
||||||
|
newState.selectedNoteIds = [action.id];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NOTE_SELECTION_END':
|
||||||
|
|
||||||
|
newState = { ...state };
|
||||||
|
newState.noteSelectionEnabled = false;
|
||||||
|
newState.selectedNoteIds = [];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NOTE_SIDE_MENU_OPTIONS_SET':
|
||||||
|
|
||||||
|
newState = { ...state };
|
||||||
|
newState.noteSideMenuOptions = action.options;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED':
|
||||||
|
newState = { ...state };
|
||||||
|
newState.disableSideMenuGestures = action.disableSideMenuGestures;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'MOBILE_DATA_WARNING_UPDATE':
|
||||||
|
|
||||||
|
newState = { ...state };
|
||||||
|
newState.isOnMobileData = action.isOnMobileData;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'KEYBOARD_VISIBLE_CHANGE':
|
||||||
|
newState = { ...state, keyboardVisible: action.visible };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NOTE_EDITOR_VISIBLE_CHANGE':
|
||||||
|
newState = { ...state, noteEditorVisible: action.visible };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reducer(newState, action) as AppState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default appReducer;
|
|
@ -67,10 +67,10 @@ import MigrationService from '@joplin/lib/services/MigrationService';
|
||||||
import { clearSharedFilesCache } from '../utils/ShareUtils';
|
import { clearSharedFilesCache } from '../utils/ShareUtils';
|
||||||
import setIgnoreTlsErrors from '../utils/TlsUtils';
|
import setIgnoreTlsErrors from '../utils/TlsUtils';
|
||||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||||
import { loadMasterKeysFromSettings, migrateMasterPassword } from '@joplin/lib/services/e2ee/utils';
|
import { loadMasterKeysFromSettings, migrateMasterPassword, migratePpk } from '@joplin/lib/services/e2ee/utils';
|
||||||
import { setRSA } from '@joplin/lib/services/e2ee/ppk';
|
import { setRSA } from '@joplin/lib/services/e2ee/ppk/ppk';
|
||||||
import RSA from '../services/e2ee/RSA.react-native';
|
import RSA from '../services/e2ee/RSA.react-native';
|
||||||
import { runIntegrationTests as runRsaIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils';
|
import { runIntegrationTests as runRsaIntegrationTests } from '@joplin/lib/services/e2ee/ppk/ppkTestUtils';
|
||||||
import { runIntegrationTests as runCryptoIntegrationTests } from '@joplin/lib/services/e2ee/cryptoTestUtils';
|
import { runIntegrationTests as runCryptoIntegrationTests } from '@joplin/lib/services/e2ee/cryptoTestUtils';
|
||||||
import { getCurrentProfile } from '@joplin/lib/services/profileConfig';
|
import { getCurrentProfile } from '@joplin/lib/services/profileConfig';
|
||||||
import { getDatabaseName, getPluginDataDir, getProfilesRootDir, getResourceDir } from '../services/profiles';
|
import { getDatabaseName, getPluginDataDir, getProfilesRootDir, getResourceDir } from '../services/profiles';
|
||||||
|
@ -356,6 +356,9 @@ const buildStartupTasks = (
|
||||||
addTask('buildStartupTasks/set up sharing', async () => {
|
addTask('buildStartupTasks/set up sharing', async () => {
|
||||||
await ShareService.instance().initialize(store, EncryptionService.instance());
|
await ShareService.instance().initialize(store, EncryptionService.instance());
|
||||||
});
|
});
|
||||||
|
addTask('buildStartupTasks/migrate PPK', async () => {
|
||||||
|
await migratePpk();
|
||||||
|
});
|
||||||
addTask('buildStartupTasks/load folders', async () => {
|
addTask('buildStartupTasks/load folders', async () => {
|
||||||
await refreshFolders(dispatch, '');
|
await refreshFolders(dispatch, '');
|
||||||
|
|
||||||
|
@ -463,11 +466,7 @@ const buildStartupTasks = (
|
||||||
// just print some messages in the console.
|
// just print some messages in the console.
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
if (Setting.value('env') === 'dev') {
|
if (Setting.value('env') === 'dev') {
|
||||||
if (Platform.OS !== 'web') {
|
await runRsaIntegrationTests();
|
||||||
await runRsaIntegrationTests();
|
|
||||||
} else {
|
|
||||||
logger.info('Skipping encryption tests -- not supported on web.');
|
|
||||||
}
|
|
||||||
await runCryptoIntegrationTests();
|
await runCryptoIntegrationTests();
|
||||||
await runOnDeviceFsDriverTests();
|
await runOnDeviceFsDriverTests();
|
||||||
}
|
}
|
||||||
|
|
|
@ -236,15 +236,20 @@ export class WorkerApi {
|
||||||
const folderName = removeReservedWords(basename(path));
|
const folderName = removeReservedWords(basename(path));
|
||||||
|
|
||||||
let handle: FileSystemDirectoryHandle;
|
let handle: FileSystemDirectoryHandle;
|
||||||
try {
|
if (!parent) {
|
||||||
handle = await parent.getDirectoryHandle(folderName, { create });
|
logger.debug('Parent not found for path', path);
|
||||||
this.directoryHandleCache_.set(path, handle);
|
|
||||||
} catch (error) {
|
|
||||||
if (!isNotFoundError(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
handle = null;
|
handle = null;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
handle = await parent.getDirectoryHandle(folderName, { create });
|
||||||
|
this.directoryHandleCache_.set(path, handle);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNotFoundError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
handle = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return handle;
|
return handle;
|
||||||
|
|
|
@ -10,13 +10,16 @@ const useKeyboardState = () => {
|
||||||
const showListener = Keyboard.addListener('keyboardDidShow', (evt) => {
|
const showListener = Keyboard.addListener('keyboardDidShow', (evt) => {
|
||||||
setKeyboardVisible(true);
|
setKeyboardVisible(true);
|
||||||
setHasSoftwareKeyboard(true);
|
setHasSoftwareKeyboard(true);
|
||||||
setKeyboardHeight(evt.endCoordinates.height);
|
// Floating keyboards on Android result in a negative height being set when portrait
|
||||||
|
setKeyboardHeight(evt.endCoordinates.height > 0 ? evt.endCoordinates.height : 0);
|
||||||
});
|
});
|
||||||
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
|
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
|
||||||
setKeyboardVisible(false);
|
setKeyboardVisible(false);
|
||||||
setKeyboardHeight(0);
|
setKeyboardHeight(0);
|
||||||
});
|
});
|
||||||
const floatingListener = Keyboard.addListener('keyboardWillChangeFrame', (evt) => {
|
const floatingListener = Keyboard.addListener('keyboardWillChangeFrame', (evt) => {
|
||||||
|
// The keyboardWillChangeFrame event only applies to iOS as it does not exist on Android, in which case isFloatingKeyboard will always be false.
|
||||||
|
// But we only need to utilise isFloatingKeyboard to workaround a KeyboardAvoidingView issue on iOS
|
||||||
const windowWidth = Dimensions.get('window').width;
|
const windowWidth = Dimensions.get('window').width;
|
||||||
// If the keyboard isn't as wide as the window, the floating keyboard is disabled.
|
// If the keyboard isn't as wide as the window, the floating keyboard is disabled.
|
||||||
// See https://github.com/facebook/react-native/issues/29473#issuecomment-696658937
|
// See https://github.com/facebook/react-native/issues/29473#issuecomment-696658937
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Only some of the React Native polyfills should be used on Web:
|
||||||
|
import './bufferPolyfill';
|
|
@ -1,15 +1,16 @@
|
||||||
import reducer from '@joplin/lib/reducer';
|
|
||||||
import { createStore } from 'redux';
|
import { createStore } from 'redux';
|
||||||
import appDefaultState from '../appDefaultState';
|
import appDefaultState from '../appDefaultState';
|
||||||
import Setting from '@joplin/lib/models/Setting';
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
import { AppState } from '../types';
|
import { AppState } from '../types';
|
||||||
|
import appReducer from '../appReducer';
|
||||||
|
|
||||||
const testReducer = (state: AppState|undefined, action: unknown): AppState => {
|
const testReducer = (state: AppState|undefined, action: unknown): AppState => {
|
||||||
state ??= {
|
state ??= {
|
||||||
...appDefaultState,
|
...appDefaultState,
|
||||||
settings: Setting.toPlainObject(),
|
settings: Setting.toPlainObject(),
|
||||||
};
|
};
|
||||||
return { ...state, ...reducer(state, action) };
|
|
||||||
|
return { ...state, ...appReducer(state, action) };
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMockReduxStore = () => {
|
const createMockReduxStore = () => {
|
||||||
|
|
|
@ -7,11 +7,11 @@ class MockPluginRunner extends BasePluginRunner {
|
||||||
public override async stop() {}
|
public override async stop() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginServiceSetup = (store: Store) => {
|
const mockPluginServiceSetup = (store: Store) => {
|
||||||
const runner = new MockPluginRunner();
|
const runner = new MockPluginRunner();
|
||||||
PluginService.instance().initialize(
|
PluginService.instance().initialize(
|
||||||
'2.14.0', { joplin: {} }, runner, store,
|
'2.14.0', { joplin: {} }, runner, store,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default pluginServiceSetup;
|
export default mockPluginServiceSetup;
|
|
@ -13,6 +13,7 @@ import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectio
|
||||||
import getSearchState from './utils/getSearchState';
|
import getSearchState from './utils/getSearchState';
|
||||||
import { noteIdFacet, setNoteIdEffect } from './extensions/selectedNoteIdExtension';
|
import { noteIdFacet, setNoteIdEffect } from './extensions/selectedNoteIdExtension';
|
||||||
import jumpToHash from './editorCommands/jumpToHash';
|
import jumpToHash from './editorCommands/jumpToHash';
|
||||||
|
import { resetImageResourceEffect } from './extensions/rendering/renderBlockImages';
|
||||||
|
|
||||||
interface Callbacks {
|
interface Callbacks {
|
||||||
onUndoRedo(): void;
|
onUndoRedo(): void;
|
||||||
|
@ -229,8 +230,12 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public onResourceDownloaded(_id: string) {
|
public onResourceChanged(id: string) {
|
||||||
// Unused
|
this.editor.dispatch({
|
||||||
|
effects: [
|
||||||
|
resetImageResourceEffect.of({ id }),
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setContentScripts(plugins: ContentScriptData[]) {
|
public setContentScripts(plugins: ContentScriptData[]) {
|
||||||
|
|
|
@ -51,7 +51,7 @@ import ctrlClickCheckboxExtension from './extensions/ctrlClickCheckboxExtension'
|
||||||
type ExtendedEditorView = typeof EditorView & { EDIT_CONTEXT: boolean };
|
type ExtendedEditorView = typeof EditorView & { EDIT_CONTEXT: boolean };
|
||||||
(EditorView as ExtendedEditorView).EDIT_CONTEXT = false;
|
(EditorView as ExtendedEditorView).EDIT_CONTEXT = false;
|
||||||
|
|
||||||
export type ResolveImageCallback = (imageSrc: string)=> Promise<string>;
|
export type ResolveImageCallback = (imageSrc: string, reloadCounter: number)=> Promise<string>;
|
||||||
|
|
||||||
interface CodeMirrorProps {
|
interface CodeMirrorProps {
|
||||||
resolveImageSrc: ResolveImageCallback;
|
resolveImageSrc: ResolveImageCallback;
|
||||||
|
@ -66,8 +66,8 @@ const createEditor = (
|
||||||
props.onLogMessage('Initializing CodeMirror...');
|
props.onLogMessage('Initializing CodeMirror...');
|
||||||
|
|
||||||
const context: RenderedContentContext = {
|
const context: RenderedContentContext = {
|
||||||
resolveImageSrc: (src) => {
|
resolveImageSrc: (src, counter) => {
|
||||||
return props.resolveImageSrc(src);
|
return props.resolveImageSrc(src, counter);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,39 @@
|
||||||
import { EditorSelection } from '@codemirror/state';
|
import { EditorSelection } from '@codemirror/state';
|
||||||
import createTestEditor from '../../testing/createTestEditor';
|
import createTestEditor from '../../testing/createTestEditor';
|
||||||
import renderBlockImages from './renderBlockImages';
|
import renderBlockImages, { resetImageResourceEffect, testing__resetImageRefreshCounterCache } from './renderBlockImages';
|
||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
|
|
||||||
const createEditor = (initialMarkdown: string, hasImage: boolean) => {
|
const allowImageUrlsToBeFetched = async () => {
|
||||||
const resolveImageSrc = jest.fn(src => Promise.resolve(src));
|
// Yield to the event loop. Since image URLs are fetched asynchronously, this is needed to
|
||||||
return createTestEditor(
|
// allow the asynchronous promise code to run
|
||||||
|
await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEditor = async (initialMarkdown: string, hasImage: boolean) => {
|
||||||
|
const resolveImageSrc = jest.fn((src, counter) => Promise.resolve(`${src}?r=${counter}`));
|
||||||
|
const editor = await createTestEditor(
|
||||||
initialMarkdown,
|
initialMarkdown,
|
||||||
EditorSelection.cursor(0),
|
EditorSelection.cursor(0),
|
||||||
hasImage ? ['Image'] : [],
|
hasImage ? ['Image'] : [],
|
||||||
[renderBlockImages({ resolveImageSrc })],
|
[renderBlockImages({ resolveImageSrc })],
|
||||||
);
|
);
|
||||||
|
await allowImageUrlsToBeFetched();
|
||||||
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const findImage = (editor: EditorView) => {
|
const findImages = (editor: EditorView) => {
|
||||||
return editor.dom.querySelector('div.cm-md-image > .image');
|
return editor.dom.querySelectorAll<HTMLDivElement>('div.cm-md-image > .image');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageBackgroundUrls = (editor: EditorView) => {
|
||||||
|
return [...findImages(editor)].map(image => image.style.backgroundImage);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('renderBlockImages', () => {
|
describe('renderBlockImages', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
testing__resetImageRefreshCounterCache();
|
||||||
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
{ spaceBefore: '', spaceAfter: '\n\n', alt: 'test' },
|
{ spaceBefore: '', spaceAfter: '\n\n', alt: 'test' },
|
||||||
{ spaceBefore: '', spaceAfter: '', alt: 'This is a test!' },
|
{ spaceBefore: '', spaceAfter: '', alt: 'This is a test!' },
|
||||||
|
@ -26,17 +42,50 @@ describe('renderBlockImages', () => {
|
||||||
])('should render images below their Markdown source (case %#)', async ({ spaceBefore, spaceAfter, alt }) => {
|
])('should render images below their Markdown source (case %#)', async ({ spaceBefore, spaceAfter, alt }) => {
|
||||||
const editor = await createEditor(`${spaceBefore}${spaceAfter}`, true);
|
const editor = await createEditor(`${spaceBefore}${spaceAfter}`, true);
|
||||||
|
|
||||||
const image = findImage(editor);
|
const images = findImages(editor);
|
||||||
expect(image).toBeTruthy();
|
expect(images).toHaveLength(1);
|
||||||
expect(image.role).toBe('image');
|
expect(images[0].role).toBe('image');
|
||||||
expect(image.ariaLabel).toBe(alt);
|
expect(images[0].ariaLabel).toBe(alt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// For now, only Joplin resources are rendered. This simplifies the implementation and avoids
|
// For now, only Joplin resources are rendered. This simplifies the implementation and avoids
|
||||||
// potentially-unwanted web requests when opening a note with only the editor open.
|
// potentially-unwanted web requests when opening a note with only the editor open.
|
||||||
test('should not render web images', async () => {
|
test('should not render web images', async () => {
|
||||||
const editor = await createEditor('\n\n', true);
|
const editor = await createEditor('\n\n', true);
|
||||||
const image = findImage(editor);
|
const images = findImages(editor);
|
||||||
expect(image).toBeNull();
|
expect(images).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow reloading specific images', async () => {
|
||||||
|
const editor = await createEditor('\n', true);
|
||||||
|
|
||||||
|
// Should have the expected original image URLs
|
||||||
|
expect(getImageBackgroundUrls(editor)).toMatchObject([
|
||||||
|
'url(:/a123456789abcdef0123456789abcdef?r=0)',
|
||||||
|
'url(:/b123456789abcdef0123456789abcde2?r=0)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
editor.dispatch({
|
||||||
|
effects: [resetImageResourceEffect.of({ id: 'a123456789abcdef0123456789abcdef' })],
|
||||||
|
});
|
||||||
|
await allowImageUrlsToBeFetched();
|
||||||
|
|
||||||
|
expect(getImageBackgroundUrls(editor)).toMatchObject([
|
||||||
|
'url(:/a123456789abcdef0123456789abcdef?r=1)',
|
||||||
|
'url(:/b123456789abcdef0123456789abcde2?r=0)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
editor.dispatch({
|
||||||
|
effects: [
|
||||||
|
resetImageResourceEffect.of({ id: 'a123456789abcdef0123456789abcdef' }),
|
||||||
|
resetImageResourceEffect.of({ id: 'b123456789abcdef0123456789abcde2' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await allowImageUrlsToBeFetched();
|
||||||
|
|
||||||
|
expect(getImageBackgroundUrls(editor)).toMatchObject([
|
||||||
|
'url(:/a123456789abcdef0123456789abcdef?r=2)',
|
||||||
|
'url(:/b123456789abcdef0123456789abcde2?r=1)',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||||
import { SyntaxNodeRef } from '@lezer/common';
|
import { SyntaxNodeRef } from '@lezer/common';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState, StateEffect, Transaction } from '@codemirror/state';
|
||||||
import { RenderedContentContext } from './types';
|
import { RenderedContentContext } from './types';
|
||||||
import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
|
import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
|
||||||
|
|
||||||
|
@ -16,22 +16,21 @@ class ImageWidget extends WidgetType {
|
||||||
private readonly context_: RenderedContentContext,
|
private readonly context_: RenderedContentContext,
|
||||||
private readonly src_: string,
|
private readonly src_: string,
|
||||||
private readonly alt_: string,
|
private readonly alt_: string,
|
||||||
|
private readonly reloadCounter_ = 0,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
public eq(other: ImageWidget) {
|
public eq(other: ImageWidget) {
|
||||||
return this.src_ === other.src_ && this.alt_ === other.alt_;
|
return this.src_ === other.src_ && this.alt_ === other.alt_ && this.reloadCounter_ === other.reloadCounter_;
|
||||||
}
|
}
|
||||||
|
|
||||||
public toDOM() {
|
public updateDOM(dom: HTMLElement): boolean {
|
||||||
const container = document.createElement('div');
|
const image = dom.querySelector<HTMLDivElement>('div.image');
|
||||||
container.classList.add(imageClassName);
|
if (!image) return false;
|
||||||
|
|
||||||
const image = document.createElement('div');
|
|
||||||
image.role = 'image';
|
|
||||||
image.ariaLabel = this.alt_;
|
image.ariaLabel = this.alt_;
|
||||||
image.classList.add('image');
|
image.role = 'image';
|
||||||
|
|
||||||
const updateImageUrl = () => {
|
const updateImageUrl = () => {
|
||||||
if (this.resolvedSrc_) {
|
if (this.resolvedSrc_) {
|
||||||
|
@ -43,14 +42,26 @@ class ImageWidget extends WidgetType {
|
||||||
|
|
||||||
if (!this.resolvedSrc_) {
|
if (!this.resolvedSrc_) {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
this.resolvedSrc_ = await this.context_.resolveImageSrc(this.src_);
|
this.resolvedSrc_ = await this.context_.resolveImageSrc(this.src_, this.reloadCounter_);
|
||||||
updateImageUrl();
|
updateImageUrl();
|
||||||
})();
|
})();
|
||||||
} else {
|
} else {
|
||||||
updateImageUrl();
|
updateImageUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toDOM() {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.classList.add(imageClassName);
|
||||||
|
|
||||||
|
const image = document.createElement('div');
|
||||||
|
image.classList.add('image');
|
||||||
|
|
||||||
container.appendChild(image);
|
container.appendChild(image);
|
||||||
|
this.updateDOM(container);
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,6 +93,16 @@ const getImageAlt = (node: SyntaxNodeRef, state: EditorState) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// In Electron: To work around browser caching, these counters should continue to increase even if an old
|
||||||
|
// editor is destroyed and a new one is created in the same window.
|
||||||
|
const imageToRefreshCounters = new Map<string, number>();
|
||||||
|
export const resetImageResourceEffect = StateEffect.define<{ id: string }>();
|
||||||
|
|
||||||
|
// Intended only for automated tests.
|
||||||
|
export const testing__resetImageRefreshCounterCache = () => {
|
||||||
|
imageToRefreshCounters.clear();
|
||||||
|
};
|
||||||
|
|
||||||
const renderBlockImages = (context: RenderedContentContext) => [
|
const renderBlockImages = (context: RenderedContentContext) => [
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
[`& .${imageClassName} > div`]: {
|
[`& .${imageClassName} > div`]: {
|
||||||
|
@ -106,7 +127,7 @@ const renderBlockImages = (context: RenderedContentContext) => [
|
||||||
if (src) {
|
if (src) {
|
||||||
const isLastLine = lineTo.number === state.doc.lines;
|
const isLastLine = lineTo.number === state.doc.lines;
|
||||||
return Decoration.widget({
|
return Decoration.widget({
|
||||||
widget: new ImageWidget(context, src, alt),
|
widget: new ImageWidget(context, src, alt, imageToRefreshCounters.get(src) ?? 0),
|
||||||
// "side: -1": In general, when the cursor is at the widget's location, it should be at
|
// "side: -1": In general, when the cursor is at the widget's location, it should be at
|
||||||
// the start of the next line (and so "side" should be -1).
|
// the start of the next line (and so "side" should be -1).
|
||||||
//
|
//
|
||||||
|
@ -128,6 +149,18 @@ const renderBlockImages = (context: RenderedContentContext) => [
|
||||||
return [Math.min(nodeLine.to + 1, state.doc.length)];
|
return [Math.min(nodeLine.to + 1, state.doc.length)];
|
||||||
},
|
},
|
||||||
hideWhenContainsSelection: false,
|
hideWhenContainsSelection: false,
|
||||||
|
|
||||||
|
shouldFullReRender: (transaction: Transaction) => {
|
||||||
|
let hadRefreshEffect = false;
|
||||||
|
for (const effect of transaction.effects) {
|
||||||
|
if (effect.is(resetImageResourceEffect)) {
|
||||||
|
const key = `:/${effect.value.id}`;
|
||||||
|
imageToRefreshCounters.set(key, (imageToRefreshCounters.get(key) ?? 0) + 1);
|
||||||
|
hadRefreshEffect = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hadRefreshEffect;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { EditorState } from '@codemirror/state';
|
import type { EditorState, Transaction } from '@codemirror/state';
|
||||||
import type { Decoration, WidgetType } from '@codemirror/view';
|
import type { Decoration, WidgetType } from '@codemirror/view';
|
||||||
import type { SyntaxNodeRef } from '@lezer/common';
|
import type { SyntaxNodeRef } from '@lezer/common';
|
||||||
|
|
||||||
|
@ -13,8 +13,11 @@ export interface ReplacementExtension {
|
||||||
|
|
||||||
// Disable the decoration when near the cursor. Defaults to true.
|
// Disable the decoration when near the cursor. Defaults to true.
|
||||||
hideWhenContainsSelection?: boolean;
|
hideWhenContainsSelection?: boolean;
|
||||||
|
|
||||||
|
// Allows specifying custom logic to refresh all decorations associated with the extension
|
||||||
|
shouldFullReRender?: (transaction: Transaction)=> boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderedContentContext {
|
export interface RenderedContentContext {
|
||||||
resolveImageSrc(src: string): Promise<string>;
|
resolveImageSrc(src: string, reloadCounter: number): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -72,7 +72,12 @@ const makeBlockReplaceExtension = (extensionSpec: ReplacementExtension) => {
|
||||||
decorations = decorations.map(transaction.changes);
|
decorations = decorations.map(transaction.changes);
|
||||||
const selectionChanged = !transaction.newSelection.eq(transaction.startState.selection);
|
const selectionChanged = !transaction.newSelection.eq(transaction.startState.selection);
|
||||||
|
|
||||||
if (transaction.docChanged || selectionChanged) {
|
const wasRerenderRequested = () => {
|
||||||
|
if (!extensionSpec.shouldFullReRender) return false;
|
||||||
|
return extensionSpec.shouldFullReRender(transaction);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (transaction.docChanged || selectionChanged || wasRerenderRequested()) {
|
||||||
decorations = updateDecorations(transaction.state, extensionSpec);
|
decorations = updateDecorations(transaction.state, extensionSpec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -271,7 +271,7 @@ const createEditor = async (
|
||||||
setContentScripts: (_plugins: ContentScriptData[]) => {
|
setContentScripts: (_plugins: ContentScriptData[]) => {
|
||||||
throw new Error('setContentScripts not implemented.');
|
throw new Error('setContentScripts not implemented.');
|
||||||
},
|
},
|
||||||
onResourceDownloaded: async (resourceId: string) => {
|
onResourceChanged: async (resourceId: string) => {
|
||||||
const rendered = await renderAndPostprocessHtml(`<img src=":/${resourceId}"/>`);
|
const rendered = await renderAndPostprocessHtml(`<img src=":/${resourceId}"/>`);
|
||||||
const renderedImage = rendered.dom.querySelector('img');
|
const renderedImage = rendered.dom.querySelector('img');
|
||||||
|
|
||||||
|
@ -285,6 +285,7 @@ const createEditor = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceSrc = renderedImage?.src;
|
const resourceSrc = renderedImage?.src;
|
||||||
|
// TODO: Handle the more general case where the resource changed externally
|
||||||
onResourceDownloaded(view, resourceId, resourceSrc);
|
onResourceDownloaded(view, resourceId, resourceSrc);
|
||||||
},
|
},
|
||||||
remove: () => {
|
remove: () => {
|
||||||
|
|
|
@ -48,10 +48,7 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
|
||||||
block.end,
|
block.end,
|
||||||
].join(''));
|
].join(''));
|
||||||
|
|
||||||
const submitButton = document.createElement('button');
|
const onClose = () => {
|
||||||
submitButton.appendChild(createTextNode(doneLabel));
|
|
||||||
submitButton.classList.add('submit');
|
|
||||||
submitButton.onclick = () => {
|
|
||||||
if (dialog.close) {
|
if (dialog.close) {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
} else {
|
} else {
|
||||||
|
@ -61,6 +58,11 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitButton = document.createElement('button');
|
||||||
|
submitButton.appendChild(createTextNode(doneLabel));
|
||||||
|
submitButton.classList.add('submit');
|
||||||
|
submitButton.onclick = onClose;
|
||||||
|
|
||||||
dialog.appendChild(submitButton);
|
dialog.appendChild(submitButton);
|
||||||
|
|
||||||
|
|
||||||
|
@ -72,7 +74,9 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
|
||||||
focus('createEditorDialog/legacy', editor);
|
focus('createEditorDialog/legacy', editor);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {
|
||||||
|
dismiss: onClose,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default createEditorDialog;
|
export default createEditorDialog;
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { htmlentities } from '@joplin/utils/html';
|
||||||
import { RenderResult } from '../../../../renderer/types';
|
import { RenderResult } from '../../../../renderer/types';
|
||||||
import createTestEditor from '../../testing/createTestEditor';
|
import createTestEditor from '../../testing/createTestEditor';
|
||||||
import joplinEditorApiPlugin, { getEditorApi, setEditorApi } from '../joplinEditorApiPlugin';
|
import joplinEditorApiPlugin, { getEditorApi, setEditorApi } from '../joplinEditorApiPlugin';
|
||||||
import joplinEditablePlugin from './joplinEditablePlugin';
|
import joplinEditablePlugin, { editSourceBlockAt, hideSourceBlockEditor } from './joplinEditablePlugin';
|
||||||
import { Second } from '@joplin/utils/time';
|
import { Second } from '@joplin/utils/time';
|
||||||
|
|
||||||
const createEditor = (html: string) => {
|
const createEditor = (html: string) => {
|
||||||
|
@ -19,7 +19,7 @@ const findEditButton = (ancestor: Element): HTMLButtonElement => {
|
||||||
const findEditorDialog = () => {
|
const findEditorDialog = () => {
|
||||||
const dialog = document.querySelector('dialog.editor-dialog');
|
const dialog = document.querySelector('dialog.editor-dialog');
|
||||||
if (!dialog) {
|
if (!dialog) {
|
||||||
throw new Error('Could not find an open editor dialog.');
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = dialog.querySelector('textarea');
|
const editor = dialog.querySelector('textarea');
|
||||||
|
@ -117,4 +117,15 @@ describe('joplinEditablePlugin', () => {
|
||||||
hashLinks[1].click();
|
hashLinks[1].click();
|
||||||
expect(editor.state.selection.$from.parent.textContent).toBe('Test heading 2');
|
expect(editor.state.selection.$from.parent.textContent).toBe('Test heading 2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should support toggling the editor dialog externally', () => {
|
||||||
|
const editor = createEditor('<div class="joplin-editable"><pre class="joplin-source">test source</pre>rendered</div>');
|
||||||
|
editSourceBlockAt(0)(editor.state, editor.dispatch, editor);
|
||||||
|
|
||||||
|
const dialog = findEditorDialog();
|
||||||
|
expect(dialog.editor).toBeTruthy();
|
||||||
|
|
||||||
|
hideSourceBlockEditor(editor.state, editor.dispatch, editor);
|
||||||
|
expect(findEditorDialog()).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Plugin } from 'prosemirror-state';
|
import { Command, EditorState, Plugin } from 'prosemirror-state';
|
||||||
import { Node, NodeSpec, TagParseRule } from 'prosemirror-model';
|
import { Node, NodeSpec, TagParseRule } from 'prosemirror-model';
|
||||||
import { EditorView, NodeView } from 'prosemirror-view';
|
import { EditorView, NodeView } from 'prosemirror-view';
|
||||||
import sanitizeHtml from '../../utils/sanitizeHtml';
|
import sanitizeHtml from '../../utils/sanitizeHtml';
|
||||||
|
@ -13,6 +13,116 @@ import makeLinksClickableInElement from '../../utils/makeLinksClickableInElement
|
||||||
// writing similar ProseMirror plugins:
|
// writing similar ProseMirror plugins:
|
||||||
// https://prosemirror.net/examples/fold/
|
// https://prosemirror.net/examples/fold/
|
||||||
|
|
||||||
|
type EditRequest = {
|
||||||
|
nodeStart: number;
|
||||||
|
showEditor: true;
|
||||||
|
} | {
|
||||||
|
nodeStart?: undefined;
|
||||||
|
showEditor: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editSourceBlockAt = (nodeStart: number): Command => (state, dispatch) => {
|
||||||
|
const node = state.doc.nodeAt(nodeStart);
|
||||||
|
if (node.type.name !== 'joplinEditableInline' && node.type.name !== 'joplinEditableBlock') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
const editRequest: EditRequest = {
|
||||||
|
nodeStart,
|
||||||
|
showEditor: true,
|
||||||
|
};
|
||||||
|
dispatch(state.tr.setMeta(joplinEditablePlugin, editRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSourceBlockEditorVisible = (state: EditorState) => {
|
||||||
|
return joplinEditablePlugin.getState(state).editingNodeAt !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hideSourceBlockEditor: Command = (state, dispatch) => {
|
||||||
|
const isEditing = isSourceBlockEditorVisible(state);
|
||||||
|
if (!isEditing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
const editRequest: EditRequest = {
|
||||||
|
showEditor: false,
|
||||||
|
};
|
||||||
|
dispatch(state.tr.setMeta(joplinEditablePlugin, editRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDialogForNode = (nodePosition: number, view: EditorView) => {
|
||||||
|
let saveCounter = 0;
|
||||||
|
|
||||||
|
const getNode = () => (
|
||||||
|
view.state.doc.nodeAt(nodePosition)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { localize: _ } = getEditorApi(view.state);
|
||||||
|
const { dismiss } = createEditorDialog({
|
||||||
|
doneLabel: _('Done'),
|
||||||
|
editorLabel: _('Code:'),
|
||||||
|
editorApi: getEditorApi(view.state),
|
||||||
|
block: {
|
||||||
|
content: getNode().attrs.source,
|
||||||
|
start: getNode().attrs.openCharacters,
|
||||||
|
end: getNode().attrs.closeCharacters,
|
||||||
|
},
|
||||||
|
onSave: async (block) => {
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr.setNodeAttribute(
|
||||||
|
nodePosition, 'source', block.content,
|
||||||
|
).setNodeAttribute(
|
||||||
|
nodePosition, 'openCharacters', block.start,
|
||||||
|
).setNodeAttribute(
|
||||||
|
nodePosition, 'closeCharacters', block.end,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
saveCounter ++;
|
||||||
|
const initialSaveCounter = saveCounter;
|
||||||
|
const cancelled = () => saveCounter !== initialSaveCounter;
|
||||||
|
|
||||||
|
// Debounce rendering
|
||||||
|
await msleep(400);
|
||||||
|
if (cancelled()) return;
|
||||||
|
|
||||||
|
const rendered = await getEditorApi(view.state).renderer.renderMarkupToHtml(
|
||||||
|
`${block.start}${block.content}${block.end}`,
|
||||||
|
{ forceMarkdown: true, isFullPageRender: false },
|
||||||
|
);
|
||||||
|
if (cancelled()) return;
|
||||||
|
|
||||||
|
const html = postProcessRenderedHtml(rendered.html, getNode().isInline);
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr.setNodeAttribute(
|
||||||
|
nodePosition, 'contentHtml', html,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDismiss: () => {
|
||||||
|
hideSourceBlockEditor(view.state, view.dispatch, view);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPositionChange: (newPosition: number) => {
|
||||||
|
nodePosition = newPosition;
|
||||||
|
},
|
||||||
|
dismiss,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type DialogHandle = ReturnType<typeof createDialogForNode>;
|
||||||
|
|
||||||
|
|
||||||
interface JoplinEditableAttributes {
|
interface JoplinEditableAttributes {
|
||||||
contentHtml: string;
|
contentHtml: string;
|
||||||
source: string;
|
source: string;
|
||||||
|
@ -117,7 +227,6 @@ export const nodeSpecs = {
|
||||||
type GetPosition = ()=> number;
|
type GetPosition = ()=> number;
|
||||||
|
|
||||||
class EditableSourceBlockView implements NodeView {
|
class EditableSourceBlockView implements NodeView {
|
||||||
private editDialogVisible_ = false;
|
|
||||||
public readonly dom: HTMLElement;
|
public readonly dom: HTMLElement;
|
||||||
public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) {
|
public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) {
|
||||||
if ((node.attrs.contentHtml ?? undefined) === undefined) {
|
if ((node.attrs.contentHtml ?? undefined) === undefined) {
|
||||||
|
@ -135,58 +244,7 @@ class EditableSourceBlockView implements NodeView {
|
||||||
}
|
}
|
||||||
|
|
||||||
private showEditDialog_() {
|
private showEditDialog_() {
|
||||||
if (this.editDialogVisible_) {
|
editSourceBlockAt(this.getPosition())(this.view.state, this.view.dispatch, this.view);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { localize: _ } = getEditorApi(this.view.state);
|
|
||||||
|
|
||||||
let saveCounter = 0;
|
|
||||||
createEditorDialog({
|
|
||||||
doneLabel: _('Done'),
|
|
||||||
editorLabel: _('Code:'),
|
|
||||||
editorApi: getEditorApi(this.view.state),
|
|
||||||
block: {
|
|
||||||
content: this.node.attrs.source,
|
|
||||||
start: this.node.attrs.openCharacters,
|
|
||||||
end: this.node.attrs.closeCharacters,
|
|
||||||
},
|
|
||||||
onSave: async (block) => {
|
|
||||||
this.view.dispatch(
|
|
||||||
this.view.state.tr.setNodeAttribute(
|
|
||||||
this.getPosition(), 'source', block.content,
|
|
||||||
).setNodeAttribute(
|
|
||||||
this.getPosition(), 'openCharacters', block.start,
|
|
||||||
).setNodeAttribute(
|
|
||||||
this.getPosition(), 'closeCharacters', block.end,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
saveCounter ++;
|
|
||||||
const initialSaveCounter = saveCounter;
|
|
||||||
const cancelled = () => saveCounter !== initialSaveCounter;
|
|
||||||
|
|
||||||
// Debounce rendering
|
|
||||||
await msleep(400);
|
|
||||||
if (cancelled()) return;
|
|
||||||
|
|
||||||
const rendered = await getEditorApi(this.view.state).renderer.renderMarkupToHtml(
|
|
||||||
`${block.start}${block.content}${block.end}`,
|
|
||||||
{ forceMarkdown: true, isFullPageRender: false },
|
|
||||||
);
|
|
||||||
if (cancelled()) return;
|
|
||||||
|
|
||||||
const html = postProcessRenderedHtml(rendered.html, this.node.isInline);
|
|
||||||
this.view.dispatch(
|
|
||||||
this.view.state.tr.setNodeAttribute(
|
|
||||||
this.getPosition(), 'contentHtml', html,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onDismiss: () => {
|
|
||||||
this.editDialogVisible_ = false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateContent_() {
|
private updateContent_() {
|
||||||
|
@ -236,13 +294,64 @@ class EditableSourceBlockView implements NodeView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const joplinEditablePlugin = new Plugin({
|
interface PluginState {
|
||||||
|
editingNodeAt: number|null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const joplinEditablePlugin = new Plugin<PluginState>({
|
||||||
|
state: {
|
||||||
|
init: () => ({
|
||||||
|
editingNodeAt: null,
|
||||||
|
}),
|
||||||
|
apply: (tr, oldValue) => {
|
||||||
|
let editingAt = oldValue.editingNodeAt;
|
||||||
|
|
||||||
|
const editRequest: EditRequest|null = tr.getMeta(joplinEditablePlugin);
|
||||||
|
if (editRequest) {
|
||||||
|
if (editRequest.showEditor) {
|
||||||
|
editingAt = editRequest.nodeStart;
|
||||||
|
} else {
|
||||||
|
editingAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingAt) {
|
||||||
|
editingAt = tr.mapping.map(editingAt, 1);
|
||||||
|
}
|
||||||
|
return { editingNodeAt: editingAt };
|
||||||
|
},
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
nodeViews: {
|
nodeViews: {
|
||||||
joplinEditableInline: (node, view, getPos) => new EditableSourceBlockView(node, true, view, getPos),
|
joplinEditableInline: (node, view, getPos) => new EditableSourceBlockView(node, true, view, getPos),
|
||||||
joplinEditableBlock: (node, view, getPos) => new EditableSourceBlockView(node, false, view, getPos),
|
joplinEditableBlock: (node, view, getPos) => new EditableSourceBlockView(node, false, view, getPos),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
view: () => {
|
||||||
|
let dialog: DialogHandle|null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(view, prevState) {
|
||||||
|
const oldState = joplinEditablePlugin.getState(prevState);
|
||||||
|
const newState = joplinEditablePlugin.getState(view.state);
|
||||||
|
|
||||||
|
if (newState.editingNodeAt !== null) {
|
||||||
|
if (oldState.editingNodeAt === null) {
|
||||||
|
dialog = createDialogForNode(newState.editingNodeAt, view);
|
||||||
|
}
|
||||||
|
dialog?.onPositionChange(newState.editingNodeAt);
|
||||||
|
} else if (dialog) {
|
||||||
|
const lastDialog = dialog;
|
||||||
|
// Set dialog to null before dismissing to prevent infinite recursion.
|
||||||
|
// Dismissing the dialog can cause the editor state to update, which can
|
||||||
|
// result in this callback being re-run.
|
||||||
|
dialog = null;
|
||||||
|
|
||||||
|
lastDialog.dismiss();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default joplinEditablePlugin;
|
export default joplinEditablePlugin;
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { baseKeymap, chainCommands, exitCode, liftEmptyBlock, newlineInCode } fr
|
||||||
import { liftListItem, sinkListItem, splitListItem } from 'prosemirror-schema-list';
|
import { liftListItem, sinkListItem, splitListItem } from 'prosemirror-schema-list';
|
||||||
import commands from '../commands';
|
import commands from '../commands';
|
||||||
import { EditorCommandType } from '../../types';
|
import { EditorCommandType } from '../../types';
|
||||||
import { Command, EditorState, TextSelection } from 'prosemirror-state';
|
import { Command, EditorState, TextSelection, Plugin } from 'prosemirror-state';
|
||||||
import splitBlockAs from '../vendor/splitBlockAs';
|
import splitBlockAs from '../vendor/splitBlockAs';
|
||||||
import canReplaceSelectionWith from '../utils/canReplaceSelectionWith';
|
import canReplaceSelectionWith from '../utils/canReplaceSelectionWith';
|
||||||
|
|
||||||
|
@ -60,6 +60,28 @@ const isInEmptyParagraph = (state: EditorState) => {
|
||||||
selectionParent.content.size === 0;
|
selectionParent.content.size === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle double-hard-break -> paragraph conversion with a Plugin to work around
|
||||||
|
// a bug on Android. If convertDoubleHardBreakToNewParagraph is handled with the
|
||||||
|
// main keymap logic (with a keymap() extension), then it's possible for the cursor
|
||||||
|
// to get stuck in some cases.
|
||||||
|
// See https://github.com/laurent22/joplin/issues/12960.
|
||||||
|
const replaceDoubleHardBreaksOnEnter = new Plugin({
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
keydown: (view, event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
const commandResult = convertDoubleHardBreakToNewParagraph(view.state, view.dispatch);
|
||||||
|
if (commandResult) {
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const insertHardBreak: Command = (state, dispatch) => {
|
const insertHardBreak: Command = (state, dispatch) => {
|
||||||
// Avoid adding hard breaks at the beginning of list items
|
// Avoid adding hard breaks at the beginning of list items
|
||||||
if (isInEmptyListItem(state)) return false;
|
if (isInEmptyListItem(state)) return false;
|
||||||
|
@ -88,12 +110,12 @@ const keymapExtension = [
|
||||||
'Mod-[': liftListItem(itemType),
|
'Mod-[': liftListItem(itemType),
|
||||||
'Mod-]': sinkListItem(itemType),
|
'Mod-]': sinkListItem(itemType),
|
||||||
})),
|
})),
|
||||||
|
replaceDoubleHardBreaksOnEnter,
|
||||||
keymap({
|
keymap({
|
||||||
'Enter': chainCommands(
|
'Enter': chainCommands(
|
||||||
newlineInCode,
|
newlineInCode,
|
||||||
exitCode,
|
exitCode,
|
||||||
liftEmptyBlock,
|
liftEmptyBlock,
|
||||||
convertDoubleHardBreakToNewParagraph,
|
|
||||||
insertHardBreak,
|
insertHardBreak,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -167,6 +167,8 @@ const nodes = addDefaultToplevelAttributes({
|
||||||
title: { default: '', validate: 'string' },
|
title: { default: '', validate: 'string' },
|
||||||
fromMd: { default: false, validate: 'boolean' },
|
fromMd: { default: false, validate: 'boolean' },
|
||||||
resourceId: { default: null as string|null, validate: 'string|null' },
|
resourceId: { default: null as string|null, validate: 'string|null' },
|
||||||
|
width: { default: '', validate: 'string' },
|
||||||
|
height: { default: '', validate: 'string' },
|
||||||
},
|
},
|
||||||
parseDOM: [
|
parseDOM: [
|
||||||
{
|
{
|
||||||
|
@ -174,6 +176,8 @@ const nodes = addDefaultToplevelAttributes({
|
||||||
getAttrs: node => ({
|
getAttrs: node => ({
|
||||||
src: node.getAttribute('src'),
|
src: node.getAttribute('src'),
|
||||||
alt: node.getAttribute('alt'),
|
alt: node.getAttribute('alt'),
|
||||||
|
width: node.getAttribute('width') ?? '',
|
||||||
|
height: node.getAttribute('height') ?? '',
|
||||||
title: node.getAttribute('title'),
|
title: node.getAttribute('title'),
|
||||||
fromMd: node.hasAttribute('data-from-md'),
|
fromMd: node.hasAttribute('data-from-md'),
|
||||||
resourceId: node.getAttribute('data-resource-id') || null,
|
resourceId: node.getAttribute('data-resource-id') || null,
|
||||||
|
@ -181,7 +185,7 @@ const nodes = addDefaultToplevelAttributes({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
toDOM: node => {
|
toDOM: node => {
|
||||||
const { src, alt, title, fromMd, resourceId } = node.attrs;
|
const { src, alt, title, width, height, fromMd, resourceId } = node.attrs;
|
||||||
const outputAttrs: Record<string, unknown> = { src, alt, title };
|
const outputAttrs: Record<string, unknown> = { src, alt, title };
|
||||||
|
|
||||||
if (fromMd) {
|
if (fromMd) {
|
||||||
|
@ -190,6 +194,12 @@ const nodes = addDefaultToplevelAttributes({
|
||||||
if (resourceId) {
|
if (resourceId) {
|
||||||
outputAttrs['data-resource-id'] = resourceId;
|
outputAttrs['data-resource-id'] = resourceId;
|
||||||
}
|
}
|
||||||
|
if (width) {
|
||||||
|
outputAttrs.width = width;
|
||||||
|
}
|
||||||
|
if (height) {
|
||||||
|
outputAttrs.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'img',
|
'img',
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
"@joplin/utils": "~3.4",
|
"@joplin/utils": "~3.4",
|
||||||
"@testing-library/react-hooks": "8.0.1",
|
"@testing-library/react-hooks": "8.0.1",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
|
"@types/node": "18.19.112",
|
||||||
"@types/react": "18.3.23",
|
"@types/react": "18.3.23",
|
||||||
"@types/react-redux": "7.1.33",
|
"@types/react-redux": "7.1.33",
|
||||||
"@types/styled-components": "5.1.32",
|
"@types/styled-components": "5.1.32",
|
||||||
|
|
|
@ -126,8 +126,9 @@ export interface EditorControl {
|
||||||
|
|
||||||
setContentScripts(plugins: ContentScriptData[]): Promise<void>;
|
setContentScripts(plugins: ContentScriptData[]): Promise<void>;
|
||||||
|
|
||||||
// Called when a resource associated with the current note finishes downloading.
|
// Called when a resource associated with the current note finishes downloading
|
||||||
onResourceDownloaded(id: string): void;
|
// or has been updated in an external editor.
|
||||||
|
onResourceChanged(id: string): void;
|
||||||
|
|
||||||
remove(): void;
|
remove(): void;
|
||||||
focus(): void;
|
focus(): void;
|
||||||
|
@ -147,7 +148,7 @@ export enum EditorKeymap {
|
||||||
export interface EditorTheme extends Theme {
|
export interface EditorTheme extends Theme {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
fontSize?: number;
|
fontSize: number;
|
||||||
fontSizeUnits?: string;
|
fontSizeUnits?: string;
|
||||||
isDesktop?: boolean;
|
isDesktop?: boolean;
|
||||||
monospaceFont?: string;
|
monospaceFont?: string;
|
||||||
|
|
|
@ -46,7 +46,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/node": "18.19.103",
|
"@types/node": "18.19.112",
|
||||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||||
"@typescript-eslint/parser": "6.21.0",
|
"@typescript-eslint/parser": "6.21.0",
|
||||||
"coveralls": "3.1.1",
|
"coveralls": "3.1.1",
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/node": "18.19.103",
|
"@types/node": "18.19.112",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"typescript": "5.8.2"
|
"typescript": "5.8.2"
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tsc": "",
|
"tsc": "",
|
||||||
"buildPluginDoc_": "typedoc --exclude '../lib/models/**' --name 'Joplin Plugin API Documentation' --mode file -theme '../../Assets/PluginDocTheme/' --readme '../../Assets/PluginDocTheme/index.md' --excludeNotExported --excludeExternals --excludePrivate --excludeProtected --out ../../../joplin-website/docs/api/references/plugin_api ../lib/services/plugins/api/"
|
"buildPluginDoc_": "typedoc --exclude '../lib/models/**' --exclude '../lib/services/e2ee/**' --name 'Joplin Plugin API Documentation' --mode file -theme '../../Assets/PluginDocTheme/' --readme '../../Assets/PluginDocTheme/index.md' --excludeNotExported --excludeExternals --excludePrivate --excludeProtected --out ../../../joplin-website/docs/api/references/plugin_api ../lib/services/plugins/api/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"typedoc": "0.17.8",
|
"typedoc": "0.17.8",
|
||||||
|
|
|
@ -51,10 +51,10 @@ import handleSyncStartupOperation from './services/synchronizer/utils/handleSync
|
||||||
import SyncTargetJoplinCloud from './SyncTargetJoplinCloud';
|
import SyncTargetJoplinCloud from './SyncTargetJoplinCloud';
|
||||||
import { setAutoFreeze } from 'immer';
|
import { setAutoFreeze } from 'immer';
|
||||||
import { getEncryptionEnabled } from './services/synchronizer/syncInfoUtils';
|
import { getEncryptionEnabled } from './services/synchronizer/syncInfoUtils';
|
||||||
import { loadMasterKeysFromSettings, migrateMasterPassword } from './services/e2ee/utils';
|
import { loadMasterKeysFromSettings, migrateMasterPassword, migratePpk } from './services/e2ee/utils';
|
||||||
import SyncTargetNone from './SyncTargetNone';
|
import SyncTargetNone from './SyncTargetNone';
|
||||||
import { setRSA } from './services/e2ee/ppk';
|
import { setRSA } from './services/e2ee/ppk/ppk';
|
||||||
import RSA from './services/e2ee/RSA.node';
|
import RSA from './services/e2ee/ppk/RSA.node';
|
||||||
import Resource from './models/Resource';
|
import Resource from './models/Resource';
|
||||||
import { ProfileConfig } from './services/profileConfig/types';
|
import { ProfileConfig } from './services/profileConfig/types';
|
||||||
import initProfile from './services/profileConfig/initProfile';
|
import initProfile from './services/profileConfig/initProfile';
|
||||||
|
@ -90,8 +90,7 @@ export default class BaseApplication {
|
||||||
private eventEmitter_: any;
|
private eventEmitter_: any;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
private scheduleAutoAddResourcesIID_: any = null;
|
private scheduleAutoAddResourcesIID_: any = null;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
protected database_: JoplinDatabase = null;
|
||||||
private database_: any = null;
|
|
||||||
private profileConfig_: ProfileConfig = null;
|
private profileConfig_: ProfileConfig = null;
|
||||||
|
|
||||||
protected showStackTraces_ = false;
|
protected showStackTraces_ = false;
|
||||||
|
@ -781,6 +780,7 @@ export default class BaseApplication {
|
||||||
options.keychainEnabled ? [KeychainServiceDriverElectron, KeychainServiceDriverNode] : [],
|
options.keychainEnabled ? [KeychainServiceDriverElectron, KeychainServiceDriverNode] : [],
|
||||||
);
|
);
|
||||||
await migrateMasterPassword();
|
await migrateMasterPassword();
|
||||||
|
await migratePpk();
|
||||||
await handleSyncStartupOperation();
|
await handleSyncStartupOperation();
|
||||||
|
|
||||||
appLogger.info(`Client ID: ${Setting.value('clientId')}`);
|
appLogger.info(`Client ID: ${Setting.value('clientId')}`);
|
||||||
|
@ -891,6 +891,10 @@ export default class BaseApplication {
|
||||||
if (!currentFolder) currentFolder = await Folder.defaultFolder();
|
if (!currentFolder) currentFolder = await Folder.defaultFolder();
|
||||||
Setting.setValue('activeFolderId', currentFolder ? currentFolder.id : '');
|
Setting.setValue('activeFolderId', currentFolder ? currentFolder.id : '');
|
||||||
|
|
||||||
|
if (currentFolder && !this.hasGui()) {
|
||||||
|
this.currentFolder_ = currentFolder;
|
||||||
|
}
|
||||||
|
|
||||||
await setupAutoDeletion();
|
await setupAutoDeletion();
|
||||||
|
|
||||||
await MigrationService.instance().run();
|
await MigrationService.instance().run();
|
||||||
|
|
|
@ -264,6 +264,7 @@ export default class JoplinDatabase extends Database {
|
||||||
todo_due: sp('When the todo is due. An alarm will be triggered on that date.'),
|
todo_due: sp('When the todo is due. An alarm will be triggered on that date.'),
|
||||||
todo_completed: sp('Tells whether todo is completed or not. This is a timestamp in milliseconds.'),
|
todo_completed: sp('Tells whether todo is completed or not. This is a timestamp in milliseconds.'),
|
||||||
source_url: sp('The full URL where the note comes from.'),
|
source_url: sp('The full URL where the note comes from.'),
|
||||||
|
is_shared: sp('Whether the note is published.'),
|
||||||
},
|
},
|
||||||
folders: {},
|
folders: {},
|
||||||
resources: {},
|
resources: {},
|
||||||
|
@ -288,6 +289,7 @@ export default class JoplinDatabase extends Database {
|
||||||
this.tableDescriptions_[n].updated_time = sp('When the %s was last updated.', singular);
|
this.tableDescriptions_[n].updated_time = sp('When the %s was last updated.', singular);
|
||||||
this.tableDescriptions_[n].user_created_time = sp('When the %s was created. It may differ from created_time as it can be manually set by the user.', singular);
|
this.tableDescriptions_[n].user_created_time = sp('When the %s was created. It may differ from created_time as it can be manually set by the user.', singular);
|
||||||
this.tableDescriptions_[n].user_updated_time = sp('When the %s was last updated. It may differ from updated_time as it can be manually set by the user.', singular);
|
this.tableDescriptions_[n].user_updated_time = sp('When the %s was last updated. It may differ from updated_time as it can be manually set by the user.', singular);
|
||||||
|
this.tableDescriptions_[n].share_id = sp('The ID of the Joplin Server/Cloud share containing the %s. Empty if not shared.', singular);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ import { FileApi, getSupportsDeltaWithItems, PaginatedList, RemoteItem } from '.
|
||||||
import JoplinDatabase from './JoplinDatabase';
|
import JoplinDatabase from './JoplinDatabase';
|
||||||
import { checkIfCanSync, fetchSyncInfo, checkSyncTargetIsValid, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
|
import { checkIfCanSync, fetchSyncInfo, checkSyncTargetIsValid, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
|
||||||
import { getMasterPassword, setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
|
import { getMasterPassword, setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
|
||||||
import { generateKeyPair } from './services/e2ee/ppk';
|
import { generateKeyPair } from './services/e2ee/ppk/ppk';
|
||||||
import syncDebugLog from './services/synchronizer/syncDebugLog';
|
import syncDebugLog from './services/synchronizer/syncDebugLog';
|
||||||
import handleConflictAction from './services/synchronizer/utils/handleConflictAction';
|
import handleConflictAction from './services/synchronizer/utils/handleConflictAction';
|
||||||
import resourceRemotePath from './services/synchronizer/utils/resourceRemotePath';
|
import resourceRemotePath from './services/synchronizer/utils/resourceRemotePath';
|
||||||
|
@ -387,7 +387,7 @@ export default class Synchronizer {
|
||||||
// 2. DELETE_REMOTE: Delete on the sync target, the items that have been deleted locally.
|
// 2. DELETE_REMOTE: Delete on the sync target, the items that have been deleted locally.
|
||||||
// 3. DELTA: Find on the sync target the items that have been modified or deleted and apply the changes locally.
|
// 3. DELTA: Find on the sync target the items that have been modified or deleted and apply the changes locally.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
public async start(options: any = null) {
|
public async start(options: any = null): Promise<any> {
|
||||||
if (!options) options = {};
|
if (!options) options = {};
|
||||||
|
|
||||||
if (this.state() !== 'idle') {
|
if (this.state() !== 'idle') {
|
||||||
|
@ -476,6 +476,7 @@ export default class Synchronizer {
|
||||||
|
|
||||||
let errorToThrow = null;
|
let errorToThrow = null;
|
||||||
let syncLock = null;
|
let syncLock = null;
|
||||||
|
let hasCaughtError = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.api().initialize();
|
await this.api().initialize();
|
||||||
|
@ -611,12 +612,25 @@ export default class Synchronizer {
|
||||||
|
|
||||||
// Safety check to avoid infinite loops.
|
// Safety check to avoid infinite loops.
|
||||||
// - In fact this error is possible if the item is marked for sync (via sync_time or force_sync) while synchronisation is in
|
// - In fact this error is possible if the item is marked for sync (via sync_time or force_sync) while synchronisation is in
|
||||||
// progress. In that case exit anyway to be sure we aren't in a loop and the item will be re-synced next time.
|
// progress. When force_sync is not true, this is because the user is typing while the sync is running, so we should continue
|
||||||
|
// looping, as we don't want the sync to stop when there are still un-synced outgoing changes, otherwise this creates a race condition
|
||||||
|
// on mobile, where additional changes made during upload are not synced and don't trigger another sync, whereas a change made immediately
|
||||||
|
// after the sync has finished will trigger another sync. Once the user has stopped typing, it can then break out of the loop and continue
|
||||||
|
// the rest of the process.
|
||||||
// - It can also happen if the item is directly modified in the sync target, and set with an update_time in the future. In that case,
|
// - It can also happen if the item is directly modified in the sync target, and set with an update_time in the future. In that case,
|
||||||
// the local sync_time will be updated to Date.now() but on the next loop it will see that the remote item still has a date ahead
|
// the local sync_time will be updated to Date.now() but on the next loop it will see that the remote item still has a date ahead
|
||||||
// and will see a conflict. There's currently no automatic fix for this - the remote item on the sync target must be fixed manually
|
// and will see a conflict. There's currently no automatic fix for this - the remote item on the sync target must be fixed manually
|
||||||
// (by setting an updated_time less than current time).
|
// (by setting an updated_time less than current time).
|
||||||
if (donePaths.indexOf(path) >= 0) throw new JoplinError(sprintf('Processing a path that has already been done: %s. sync_time was not updated? Remote item has an updated_time in the future?', path), 'processingPathTwice');
|
if (donePaths.indexOf(path) >= 0) {
|
||||||
|
const syncItem = await BaseItem.syncItem(syncTargetId, local.id, { fields: ['force_sync'] });
|
||||||
|
if (local.updated_time > time.unixMs()) {
|
||||||
|
throw new JoplinError(sprintf('Processing a path that has already been done: %s. Remote item has an updated_time in the future', path), 'processingPathTwice');
|
||||||
|
} else if (syncItem.force_sync) {
|
||||||
|
throw new JoplinError(sprintf('Processing a path that has already been done: %s. Item was marked for sync using force_sync', path), 'processingPathTwice');
|
||||||
|
} else {
|
||||||
|
logger.info(sprintf('Processing a path that has already been done: %s. The user is making changes while the sync is in progress', path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const remote: RemoteItem = result.neverSyncedItemIds.includes(local.id) ? null : await this.apiCall('stat', path);
|
const remote: RemoteItem = result.neverSyncedItemIds.includes(local.id) ? null : await this.apiCall('stat', path);
|
||||||
let action: SyncAction = null;
|
let action: SyncAction = null;
|
||||||
|
@ -1121,6 +1135,8 @@ export default class Synchronizer {
|
||||||
}
|
}
|
||||||
} // DELTA STEP
|
} // DELTA STEP
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
hasCaughtError = true;
|
||||||
|
|
||||||
if (error.code === ErrorCode.MustUpgradeApp) {
|
if (error.code === ErrorCode.MustUpgradeApp) {
|
||||||
this.dispatch({
|
this.dispatch({
|
||||||
type: 'MUST_UPGRADE_APP',
|
type: 'MUST_UPGRADE_APP',
|
||||||
|
@ -1163,9 +1179,12 @@ export default class Synchronizer {
|
||||||
|
|
||||||
this.syncTargetIsLocked_ = false;
|
this.syncTargetIsLocked_ = false;
|
||||||
|
|
||||||
|
let cancelledBeforeClearedState = false;
|
||||||
|
|
||||||
if (this.cancelling()) {
|
if (this.cancelling()) {
|
||||||
logger.info('Synchronisation was cancelled.');
|
logger.info('Synchronisation was cancelled.');
|
||||||
this.cancelling_ = false;
|
this.cancelling_ = false;
|
||||||
|
cancelledBeforeClearedState = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.progressReport_.completedTime = time.unixMs();
|
this.progressReport_.completedTime = time.unixMs();
|
||||||
|
@ -1174,8 +1193,10 @@ export default class Synchronizer {
|
||||||
|
|
||||||
await this.logSyncSummary(this.progressReport_);
|
await this.logSyncSummary(this.progressReport_);
|
||||||
|
|
||||||
|
const hasErrors = Synchronizer.reportHasErrors(this.progressReport_);
|
||||||
|
|
||||||
eventManager.emit(EventName.SyncComplete, {
|
eventManager.emit(EventName.SyncComplete, {
|
||||||
withErrors: Synchronizer.reportHasErrors(this.progressReport_),
|
withErrors: hasErrors,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
|
@ -1190,6 +1211,19 @@ export default class Synchronizer {
|
||||||
|
|
||||||
if (errorToThrow) throw errorToThrow;
|
if (errorToThrow) throw errorToThrow;
|
||||||
|
|
||||||
|
// If there are any un-synced outgoing changes made up to the point just before the sync completes, then trigger the sync again to reduce the likelihood
|
||||||
|
// that the user will close or minimise the app when there are un-synced changes, because the sync is reported as completed.
|
||||||
|
// IMPORTANT: This must be the very last step in the sync, to avoid any window to allow an un-synced change to get missed
|
||||||
|
if (!hasErrors && !hasCaughtError && !cancelledBeforeClearedState && !this.cancelling()) {
|
||||||
|
const result = await BaseItem.itemsThatNeedSync(syncTargetId);
|
||||||
|
options.context = outputContext;
|
||||||
|
|
||||||
|
if (result.items.length > 0) {
|
||||||
|
logger.info('There are more outgoing changes to sync, trigger the sync again');
|
||||||
|
return await this.start(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return outputContext;
|
return outputContext;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,7 +44,7 @@ export const runtime = (): CommandRuntime => {
|
||||||
throw new Error(`No controller registered for editor view ${editorView.id}`);
|
throw new Error(`No controller registered for editor view ${editorView.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousVisible = editorView.parentWindowId === windowId && controller.isVisible();
|
const previousVisible = editorView.parentWindowId === windowId && controller.visible;
|
||||||
|
|
||||||
if (show && previousVisible) {
|
if (show && previousVisible) {
|
||||||
logger.info(`Editor is already visible: ${editorViewId}`);
|
logger.info(`Editor is already visible: ${editorViewId}`);
|
||||||
|
@ -68,7 +68,7 @@ export const runtime = (): CommandRuntime => {
|
||||||
};
|
};
|
||||||
Setting.setValue('plugins.shownEditorViewIds', getUpdatedShownViewIds());
|
Setting.setValue('plugins.shownEditorViewIds', getUpdatedShownViewIds());
|
||||||
|
|
||||||
controller.setOpened(show);
|
await controller.setOpen(show);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -23,7 +23,7 @@ const useDeleteHistoryClick = ({
|
||||||
if (response === 0) {
|
if (response === 0) {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await Revision.deleteHistoryForNote(noteId);
|
await Revision.deleteHistoryForNote(noteId, { sourceDescription: 'useDeleteHistoryClick' });
|
||||||
await shim.showMessageBox(_('Note history has been deleted.'), { type: MessageBoxType.Info });
|
await shim.showMessageBox(_('Note history has been deleted.'), { type: MessageBoxType.Info });
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue