Compare commits

...

84 Commits
v3.4.11 ... dev

Author SHA1 Message Date
renovate[bot] 72698ec573
Update dependency @react-native/metro-config to v0.79.3 (#13198)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-09-14 09:35:05 +01:00
renovate[bot] 68abc27c6a
Update dependency @types/react to v18.3.23 (#13200)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-09-14 09:34:56 +01:00
renovate[bot] 1acb3d0726
Update dependency @rollup/plugin-commonjs to v28.0.5 (#13199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-09-14 09:34:48 +01:00
Joplin Bot 5bf97dc3b8 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-14 01:38:57 +00:00
Laurent Cozic e0e04fbc91 Chore: Fixed type error 2025-09-14 00:44:36 +01:00
Laurent Cozic 625cd1221c Doc: Update sponsors 2025-09-14 00:41:03 +01:00
Henry Heino 110d5bde2d
Desktop: Fix error dialogs fail to appear in certain cases (#13179) 2025-09-13 14:21:38 +01:00
renovate[bot] 93a85b3207
Update dependency @types/node to v18.19.111 (#13165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-09-13 14:18:04 +01:00
renovate[bot] ff305f42fd
Update dependency @js-draw/material-icons to v1.30.1 (#13164)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-09-13 14:17:50 +01:00
Henry Heino 99ba854ee1
Chore: Cli: Fix CLI app integration tests (#13089) 2025-09-13 14:17:40 +01:00
mrjo118 38b368e997
Mobile: Resolves #12936: Allow expanding and collapsing the title field across multiple lines (#13016) 2025-09-13 14:08:31 +01:00
Henry Heino f9ffe6c4e6
Desktop,Mobile,Cli: Fix notes are moved to the conflict folder when a folder is unshared (#12993)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-09-13 14:08:19 +01:00
pedr 5adc0170fc
All: Resolves #8718: Delete all note revisions when the note is permanently deleted (#12609) 2025-09-13 14:06:56 +01:00
mrjo118 f54c364b4d
Desktop, Mobile: Automatically retrigger the sync if there are more unsynced outgoing changes when sync completes (#12989) 2025-09-13 14:05:31 +01:00
mrjo118 9f541b9b9d
Desktop, Mobile: Add support for mixed case tags (#12931)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2025-09-13 14:01:33 +01:00
Henry Heino bd0af08c57
Docs: REST API: Add descriptions for "is_shared" and "share_id" (#13186) 2025-09-13 13:52:46 +01:00
Henry Heino ac06c6750d
Android: Fixes #13113: Fix compatibility with 16-KB-page-size devices: Remove Vosk (#13189) 2025-09-13 13:52:12 +01:00
renovate[bot] 23b07094b7
Update dependency @react-native/babel-preset to v0.79.3 (#13195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-13 13:48:57 +01:00
Joplin Bot 7eefc016de Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-13 12:45:33 +00:00
Laurent Cozic c002be76cd Doc: Update sponsors 2025-09-13 12:00:32 +01:00
renovate[bot] 2cd29aaaea
Update dependency react-native-image-picker to v8.2.1 (#13002)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-09-12 22:48:44 +01:00
Laurent Cozic 4cb6b01c71 Server: Clean-up SAML login section 2025-09-12 15:14:22 +01:00
Joplin Bot 91c79b9488 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-10 12:42:24 +00:00
Henry Heino fc516d05b3
Docs: Update the Rich Text Editor documentation (#13171) 2025-09-10 10:44:29 +01:00
Henry Heino 2769c9586c
Mobile: Fixes #13138: Rich Text Editor: Fix image size lost on change (#13172) 2025-09-10 10:06:40 +01:00
Henry Heino fd15d5a6d3
Mobile: Rich Text Editor: Accessibility: Fix font size setting not respected (#13174) 2025-09-10 10:05:51 +01:00
krevad 7237d7faa7
All: Translation: Update sv.po (#13170)
Co-authored-by: Helmut K. C. Tessarek <tessarek@evermeet.cx>
2025-09-09 19:37:19 -04:00
Henry Heino 3025d62568
Chore: Fix CI (#13168) 2025-09-09 22:31:55 +01:00
renovate[bot] 5b5dcf34a1
Update dependency @axe-core/playwright to v4.10.2 (#13162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 09:54:36 +01:00
Laurent Cozic 9e8500c148 Server v3.4.3 2025-09-09 09:47:29 +01:00
Laurent Cozic 4f1999f921 Merge branch 'release-3.4' into dev 2025-09-09 09:46:39 +01:00
Laurent Cozic 6ee9571069 iOS 13.4.3 2025-09-09 09:25:40 +01:00
krevad 10663b1494
All: Translation: Update sv.po (#13163) 2025-09-09 04:15:15 -04:00
Laurent Cozic f25db9bbd7 Android 3.4.7 2025-09-09 09:14:11 +01:00
renovate[bot] eac995a209
Update dependency esbuild to v0.25.5 (#13040)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2025-09-09 00:32:19 +01:00
Henry Heino 15c973e885
Chore: Mobile: Add additional plugin panel integration tests (#13152) 2025-09-09 00:31:12 +01:00
Henry Heino 1762f9485f
Web: Fixes #13153: Fix installing certain plugins (#13154) 2025-09-09 00:30:48 +01:00
Henry Heino 7777f8428f
Desktop: Upgrade to Electron 37.4.0 (#13156) 2025-09-09 00:30:06 +01:00
Henry Heino 948aa9db4f
Mobile: Upgrade react-native-quick-crypto to v0.7.17 (#13155) 2025-09-09 00:07:29 +01:00
Henry Heino fdde04ee85
Desktop,Mobile,Cli: Support accepting shares with a new key format (#12829) 2025-09-08 23:56:40 +01:00
Laurent Cozic f77a20f5d5 Merge branch 'release-3.4' into dev 2025-09-08 23:55:24 +01:00
Henry Heino d43aa2a3e6
Web: Update the beta notice (#13150) 2025-09-08 23:47:19 +01:00
Henry Heino 04d5ce13c2
Chore: Android: Compile Whisper with support for 16 KB pages (#13118) 2025-09-08 16:50:48 +01:00
Laurent Cozic 3b764ba06a
Server: Remove the need to install pm2-logrotate on startup so that image can work in a closed environment (#13149) 2025-09-08 16:37:35 +01:00
Henry Heino 5492ce55fa
Server: Fixes #12984: Improve handling of concurrent deletion requests for the same item (#13092) 2025-09-08 12:03:20 +01:00
Henry Heino f6b3f9860c
Cli: Fix last change sometimes lost when not in TUI mode (#13090) 2025-09-08 12:03:13 +01:00
Henry Heino 88f687ba6a
Chore: Sync fuzzer: Add actions for publishing and unpublishing notes (#13062) 2025-09-08 12:02:53 +01:00
Henry Heino 1f0a98999f
Desktop, Mobile: Fixes #12987: Fix images rendered in the Markdown editor don't reload when downloaded (#13045) 2025-09-08 12:01:54 +01:00
mrjo118 69135c3bea
Mobile: Fixes #12956: Resize the notes menu to the viewport when the keyboard is open (#13035) 2025-09-08 12:01:24 +01:00
pedr c27d542a4b
Desktop: Fixes #12049: Fix files without extension not being imported properly (#12974)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2025-09-08 11:46:36 +01:00
mrjo118 bd1c2534c5
Mobile: Fixes #13095: Fix long note title doesn’t wrap properly for To Do type note (#13099) 2025-09-08 11:05:19 +01:00
mrjo118 72513b520c
Android: Fixes #13079: Fix dropdown menus are offset on Android 15+ (#13106) 2025-09-08 11:04:46 +01:00
Henry Heino ec0f9ef9bc
Server: Fix unique constraint error when multiple `createSharedFolderUserItems` are run concurrently (#13112) 2025-09-08 11:03:28 +01:00
Henry Heino 818bc3218a
Mobile: Improve tag dialog performance with long tags and many tags (#13117) 2025-09-08 11:03:01 +01:00
Henry Heino 82760a5b6a
Web: Show a "Give feedback" banner and link to a survey (#13125) 2025-09-08 10:59:40 +01:00
mrjo118 5ba9a16cfd
Mobile: Fixes #13116: Fix tag association screen no longer searches case insensitively or searches tag endings (#13128) 2025-09-08 10:59:01 +01:00
Henry Heino 68fc91fdc7
Desktop: Resolves #13096: Prefer user-specified CSS page sizing when printing to PDF (#13130) 2025-09-08 10:58:16 +01:00
Henry Heino bdc4687327
Chore: Refactor WebViewController (#13133) 2025-09-08 10:56:51 +01:00
summoner 5e1909cee0
All: Translation: Update hu_HU.po (#13142) 2025-09-08 00:34:54 -04:00
pplulee 2e7b312415
All: Translation: Update zh_CN.po (#13137) 2025-09-06 17:02:31 -04:00
Joplin Bot 7735a59fc1 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-04 18:26:55 +00:00
Laurent Cozic 41d6e912a7 Doc: Updated sponsors 2025-09-04 17:43:49 +02:00
Joplin Bot 4c2fae8423 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-03 01:00:02 +00:00
Laurent Cozic b72c134890 Doc: Update sponsors 2025-09-02 23:01:06 +02:00
Joplin Bot 58a9c229bb Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-01 18:25:20 +00:00
Laurent Cozic d8c203bb8a Merge branch 'release-3.4' into dev 2025-09-01 14:48:55 +02:00
Laurent Cozic 9020c07825 lock files 2025-09-01 14:48:51 +02:00
Joplin Bot d134ea8bfe Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-01 12:33:37 +00:00
Henry Heino faa44468f3
Mobile: Plugins: Improve handling of invalid toolbar button enabled conditions (#13076) 2025-09-01 13:50:59 +02:00
Joplin Bot b9c5b8f187 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-01 01:12:51 +00:00
Henry Heino da8e638359
Chore: Mobile: Add test to verify that content scripts load in the note viewer (#13093) 2025-08-31 00:32:10 +02:00
Henry Heino 56ed471a2f
Chore: Rich Text Editor: Refactor editor dialog to simplify toggling the dialog from external commands (#13082) 2025-08-29 23:28:11 +02:00
Henry Heino 650594ecea
Chore: Sync fuzzer: Add action for deleting notes (#13083) 2025-08-29 23:28:00 +02:00
Eric Duarte 78fb07d4c7
All: Translation: Update ca.po (#13065) 2025-08-28 17:50:34 -04:00
Henry Heino 78c5c4d7c3
Android: Accessibility: Fix tag search input loses focus when submitted by pressing "enter" (#13070) 2025-08-28 09:20:10 +03:00
Henry Heino 57093b35ea
Android: Fixes #12960: Rich Text Editor: Fix pressing enter does nothing in some cases (#13075) 2025-08-28 09:03:37 +03:00
Henry Heino bc2832e78f
Chore: Desktop: Allow access to more Joplin APIs from the desktop development tools in dev mode (#13052) 2025-08-27 22:05:52 +03:00
Henry Heino 424cc96d36
Chore: Sync fuzzer: Fix incorrect expected state after removing the last user from a share (#13061) 2025-08-27 22:03:17 +03:00
Henry Heino 56fd5d828f
Android: Fixes #12952: External keyboard: Fix adding tags by pressing enter on certain Android devices (#13069) 2025-08-27 22:02:48 +03:00
Henry Heino 03843b087a
Desktop: Fixes #12816: Accessibility: Fix dismissing the alarm dialog by pressing escape (#13068) 2025-08-27 22:02:34 +03:00
Joplin Bot f6851314d2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-27 18:26:06 +00:00
Laurent Cozic eaec45cb3f Doc: Update sponsors 2025-08-27 18:38:56 +03:00
Laurent Cozic 9be954496c Doc: Update sponsors 2025-08-27 17:45:40 +03:00
Joplin Bot 98ef5e619b Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-27 12:33:13 +00:00
190 changed files with 4457 additions and 3139 deletions

View File

@ -96,6 +96,7 @@ packages/onenote-converter/pkg/onenote_converter.js
packages/app-cli/app/LinkSelector.js
packages/app-cli/app/app.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-attach.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/types.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/Icon.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/mockRepositoryApiConstructor.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/usePluginCallbacks.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/commands/stateToWhenClauseContext.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/plugins/PlatformImplementation.js
packages/app-mobile/services/profiles/index.js
packages/app-mobile/services/voiceTyping/VoiceTyping.js
packages/app-mobile/services/voiceTyping/utils/unzip.android.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.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/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/appReducer.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/buildStartupTasks.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/crypto-polyfill/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/shareFile.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/getWebViewDomById.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/testingLibrary.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/e2ee/EncryptionService.test.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.js
packages/lib/services/e2ee/cryptoShared.js
packages/lib/services/e2ee/cryptoTestUtils.js
packages/lib/services/e2ee/ppk.test.js
packages/lib/services/e2ee/ppk.js
packages/lib/services/e2ee/ppkTestUtils.js
packages/lib/services/e2ee/ppk/RSA.node.js
packages/lib/services/e2ee/ppk/ppk.test.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/utils.test.js
packages/lib/services/e2ee/utils.js

View File

@ -40,4 +40,29 @@ jobs:
cd packages/app-mobile/android
sed -i -- 's/signingConfig signingConfigs.release/signingConfig signingConfigs.debug/' app/build.gradle
./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"

23
.gitignore vendored
View File

@ -69,6 +69,7 @@ docs/**/*.mustache
packages/app-cli/app/LinkSelector.js
packages/app-cli/app/app.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-attach.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/types.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/Icon.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/mockRepositoryApiConstructor.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/usePluginCallbacks.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/commands/stateToWhenClauseContext.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/plugins/PlatformImplementation.js
packages/app-mobile/services/profiles/index.js
packages/app-mobile/services/voiceTyping/VoiceTyping.js
packages/app-mobile/services/voiceTyping/utils/unzip.android.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.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/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/appReducer.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/buildStartupTasks.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/crypto-polyfill/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/shareFile.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/getWebViewDomById.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/testingLibrary.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/e2ee/EncryptionService.test.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.js
packages/lib/services/e2ee/cryptoShared.js
packages/lib/services/e2ee/cryptoTestUtils.js
packages/lib/services/e2ee/ppk.test.js
packages/lib/services/e2ee/ppk.js
packages/lib/services/e2ee/ppkTestUtils.js
packages/lib/services/e2ee/ppk/RSA.node.js
packages/lib/services/e2ee/ppk/ppk.test.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/utils.test.js
packages/lib/services/e2ee/utils.js

View File

@ -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

View File

@ -63,11 +63,22 @@ FROM node:18-slim
ARG user=joplin
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
COPY --chown=$user:$user --from=builder /build/packages /home/$user/packages
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 RUNNING_IN_DOCKER=1
EXPOSE ${APP_PORT}

View File

@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
# Sponsors
<!-- SPONSORS-ORG -->
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://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 -->
* * *

View File

@ -101,7 +101,6 @@
"packageManager": "yarn@4.9.2",
"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-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",
"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",

View File

@ -434,6 +434,7 @@ class Application extends BaseApplication {
}
await Setting.saveAll();
await this.database_.close();
// Need to call exit() explicitly, otherwise Node wait for any timeout to complete
// https://stackoverflow.com/questions/18050095

View File

@ -2,33 +2,44 @@
/* eslint-disable no-console */
const fs = require('fs-extra');
const Logger = require('@joplin/utils/Logger').default;
const { dirname } = require('@joplin/lib/path-utils');
import * as fs from 'fs-extra';
import Logger, { TargetType } from '@joplin/utils/Logger';
import { dirname } from '@joplin/lib/path-utils';
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
const JoplinDatabase = require('@joplin/lib/JoplinDatabase').default;
const BaseModel = require('@joplin/lib/BaseModel').default;
const Folder = require('@joplin/lib/models/Folder').default;
const Note = require('@joplin/lib/models/Note').default;
const Setting = require('@joplin/lib/models/Setting').default;
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import BaseModel from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
const { sprintf } = require('sprintf-js');
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 joplinAppPath = `${__dirname}/main.js`;
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null });
require('@joplin/lib/testing/test-utils');
const logger = new Logger();
logger.addTarget('console');
logger.addTarget(TargetType.Console);
logger.setLevel(Logger.LEVEL_ERROR);
const dbLogger = new Logger();
dbLogger.addTarget('console');
dbLogger.addTarget(TargetType.Console);
dbLogger.setLevel(Logger.LEVEL_INFO);
const db = new JoplinDatabase(new DatabaseDriverNode());
db.setLogger(dbLogger);
function createClient(id) {
interface Client {
id: number;
profileDir: string;
}
function createClient(id: number): Client {
return {
id: id,
profileDir: `${baseDir}/client${id}`,
@ -37,13 +48,13 @@ function createClient(id) {
const client = createClient(1);
function execCommand(client, command) {
function execCommand(client: Client, command: string) {
const exePath = `node ${joplinAppPath}`;
const cmd = `${exePath} --update-geolocation-disabled --env dev --profile ${client.profileDir} ${command}`;
logger.info(`${client.id}: ${command}`);
return new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => {
return new Promise<string>((resolve, reject) => {
exec(cmd, (error: string, stdout: string, stderr: string) => {
if (error) {
logger.error(stderr);
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));
process.stdout.write('.');
}
function assertFalse(v) {
function assertFalse(v: unknown) {
if (v) throw new Error(sprintf('Expected "false", got "%s"."', v));
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));
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']);
}
const testUnits = {};
const testUnits: Record<string, ()=> Promise<void>> = {};
testUnits.testFolders = async () => {
await execCommand(client, 'mkbook nb1');
@ -85,10 +96,16 @@ testUnits.testFolders = async () => {
await execCommand(client, 'mkbook nb1');
folders = await Folder.all();
assertEquals(1, folders.length);
assertEquals(2, folders.length);
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();
assertEquals(0, folders.length);
@ -102,7 +119,7 @@ testUnits.testNotes = async () => {
assertEquals(1, notes.length);
assertEquals('n1', notes[0].title);
await execCommand(client, 'rm -f n1');
await execCommand(client, 'rmnote -p -f n1');
notes = await Note.all();
assertEquals(0, notes.length);
@ -112,12 +129,19 @@ testUnits.testNotes = async () => {
notes = await Note.all();
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();
assertEquals(2, notes.length);
await execCommand(client, 'rm -f \'n*\'');
await execCommand(client, 'rmnote -f -p \'n*\'');
notes = await Note.all();
assertEquals(0, notes.length);
@ -140,10 +164,12 @@ testUnits.testCat = async () => {
testUnits.testConfig = async () => {
await execCommand(client, 'config editor vim');
await Setting.reset();
await Setting.load();
assertEquals('vim', Setting.value('editor'));
await execCommand(client, 'config editor subl');
await Setting.reset();
await Setting.load();
assertEquals('subl', Setting.value('editor'));
@ -201,15 +227,47 @@ testUnits.testMv = async () => {
await execCommand(client, 'mknote note2');
await execCommand(client, 'mknote note3');
await execCommand(client, 'mknote blabla');
await execCommand(client, 'mv \'note*\' nb2');
notes1 = await Note.previews(f1.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(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() {
await fs.remove(baseDir);
@ -217,7 +275,9 @@ async function main() {
await db.open({ name: `${client.profileDir}/database.sqlite` });
BaseModel.setDb(db);
await Setting.load();
Setting.setConstant('rootProfileDir', client.profileDir);
Setting.setConstant('profileDir', client.profileDir);
await loadKeychainServiceAndSettings([]);
let onlyThisTest = 'testMv';
onlyThisTest = '';
@ -234,7 +294,7 @@ async function main() {
}
}
main(process.argv).catch(error => {
main().catch(error => {
console.info('');
logger.error(error);
});

View File

@ -73,7 +73,7 @@
"@joplin/tools": "~3.4",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.103",
"@types/node": "18.19.111",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",

View File

@ -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.

View File

@ -86,8 +86,14 @@ export default class InteropServiceHelper {
// pdfs.
// https://github.com/laurent22/joplin/issues/6254.
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(options as any);
const data = await win.webContents.printToPDF({
...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);
} catch (error) {
reject(error);

View File

@ -63,6 +63,8 @@ import { refreshFolders } from '@joplin/lib/folders-screen-utils';
import initializeCommandService from './utils/initializeCommandService';
import OcrDriverBase from '@joplin/lib/services/ocr/OcrDriverBase';
import PerformanceLogger from '@joplin/lib/PerformanceLogger';
import Note from '@joplin/lib/models/Note';
import Resource from '@joplin/lib/models/Resource';
const perfLogger = PerformanceLogger.create();
@ -683,6 +685,11 @@ class Application extends BaseApplication {
debug: new DebugService(reg.db()),
resourceService: ResourceService.instance(),
searchEngine: SearchEngine.instance(),
shim,
Note,
Folder,
Resource,
Setting,
ocrService: () => this.ocrService_,
};
});

View File

@ -15,7 +15,7 @@ import { connect } from 'react-redux';
import { AppState } from '../../app.reducer';
import Setting from '@joplin/lib/models/Setting';
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 MacOSMissingPasswordHelpLink from '../ConfigScreen/controls/MissingPasswordHelpLink';

View File

@ -30,6 +30,7 @@ import useEditorSearchHandler from '../utils/useEditorSearchHandler';
import CommandService from '@joplin/lib/services/CommandService';
import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange';
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);
@ -272,6 +273,17 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
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(() => {
if (!webviewReady) return;

View File

@ -110,12 +110,12 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
const editor = createEditor(editorContainerRef.current, {
...editorProps,
resolveImageSrc: async src => {
resolveImageSrc: async (src, reloadCounter) => {
const url = parseResourceUrl(src);
if (!url.itemId) return null;
const item = await Resource.load(url.itemId);
if (!item) return null;
return `${getResourceBaseUrl()}/${resourceFilename(item)}`;
return `${getResourceBaseUrl()}/${resourceFilename(item)}${reloadCounter ? `?r=${reloadCounter}` : ''}`;
},
});
editor.addStyles({

View File

@ -13,6 +13,7 @@ import { MarkupToHtmlOptions } from '../../hooks/useMarkupToHtml';
import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
import { RefObject, SetStateAction } from 'react';
import * as React from 'react';
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
export interface AllAssetsOptions {
contentMaxWidthTarget?: string;
@ -214,10 +215,8 @@ export function defaultFormNote(): FormNote {
}
export interface ResourceInfo {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
localState: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
item: any;
localState: ResourceLocalStateEntity;
item: ResourceEntity;
}
export interface ResourceInfos {

View File

@ -251,8 +251,6 @@ export default class PromptDialog extends React.Component<Props, any> {
} else {
onClose(true);
}
} else if (event.key === 'Escape') {
onClose(false);
}
};
@ -309,7 +307,7 @@ export default class PromptDialog extends React.Component<Props, any> {
}
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>
<div style={{ display: 'inline-block', color: 'black', backgroundColor: theme.backgroundColor }}>
{inputComp}

View File

@ -72,4 +72,10 @@ export default class MainScreen {
await setFilePickerResponse(electronApp, [path]);
await activateMainMenuItem(electronApp, 'HTML - HTML document (Directory)', 'Import');
}
public async pluginPanelLocator(pluginId: string) {
return this.page.locator(
`iframe[id^=${JSON.stringify(`plugin-view-${pluginId}`)}]`,
);
}
}

View File

@ -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 }) => {
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/showToast.js']);
const mainScreen = await new MainScreen(mainWindow).setup();
@ -122,5 +157,30 @@ test.describe('pluginApi', () => {
await msleep(Second);
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();
});
});

View File

@ -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,
}));
},
});
},
});

View File

@ -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');
},
});
},
});

View File

@ -131,7 +131,7 @@
"homepage": "https://github.com/laurent22/joplin#readme",
"devDependencies": {
"7zip-bin": "5.2.0",
"@axe-core/playwright": "4.10.1",
"@axe-core/playwright": "4.10.2",
"@electron/notarize": "2.5.0",
"@electron/rebuild": "3.7.2",
"@fortawesome/fontawesome-free": "5.15.4",
@ -147,7 +147,7 @@
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.14",
"@types/mustache": "4.2.6",
"@types/node": "18.19.103",
"@types/node": "18.19.111",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@types/react-redux": "7.1.33",
@ -160,7 +160,7 @@
"compare-versions": "6.1.1",
"countable": "3.0.1",
"debounce": "1.2.1",
"electron": "35.7.5",
"electron": "37.4.0",
"electron-builder": "24.13.3",
"electron-updater": "6.6.2",
"electron-window-state": "5.0.3",

View File

@ -89,8 +89,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097779
versionName "3.4.6"
versionCode 2097780
versionName "3.4.7"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}
@ -100,6 +100,8 @@ android {
externalNativeBuild {
cmake {
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"
}
}
}

View File

@ -38,8 +38,9 @@ add_library(${CMAKE_PROJECT_NAME} SHARED
set(WHISPER_LIB_DIR ${CMAKE_SOURCE_DIR}/../../../../vendor/whisper.cpp)
# Based on the Whisper.cpp Android example:
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -O3 ")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -fvisibility=hidden -fvisibility-inlines-hidden -ffunction-sections -fdata-sections")
set(SHARED_FLAGS "-O3 ")
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
add_subdirectory(${WHISPER_LIB_DIR} ./whisper)

View File

@ -24,29 +24,8 @@ buildscript {
allprojects {
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()
maven { url 'https://www.jitpack.io' }
mavenCentral()
maven {
// expo-camera bundles a custom com.google.android:cameraview

View File

@ -66,12 +66,12 @@ describe('ComboBox', () => {
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 = [
{ title: 'a' },
{ title: 'b' },
{ title: 'c' },
{ title: 'aa' },
{ title: 'Aa' },
];
const { unmount } = render(
<WrappedComboBox items={testItems}/>,
@ -82,7 +82,7 @@ describe('ComboBox', () => {
const updatedResults = getSearchResults();
expect(updatedResults[0]).toHaveTextContent('a');
expect(updatedResults[1]).toHaveTextContent('aa');
expect(updatedResults[1]).toHaveTextContent('Aa');
expect(updatedResults).toHaveLength(2);
unmount();

View File

@ -12,7 +12,7 @@ import focusView from '../utils/focusView';
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
import NestableFlatList, { NestableFlatListControl } from './NestableFlatList';
import useKeyboardState from '../utils/hooks/useKeyboardState';
const naturalCompare = require('string-natural-compare');
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
export interface Option {
@ -64,17 +64,20 @@ interface UseSearchResultsOptions {
const useSearchResults = ({
search, setSearch, options, onAddItem, canAddItem,
}: UseSearchResultsOptions) => {
const collatorLocale = getCollatorLocale();
const results = useMemo(() => {
const collator = getCollator(collatorLocale);
const lowerSearch = search?.toLowerCase();
return options
.filter(option => option.title.startsWith(search))
.filter(option => option.title.toLowerCase().includes(lowerSearch))
.sort((a, b) => {
if (a.title === b.title) return 0;
// Full matches should go first
if (a.title === search) return -1;
if (b.title === search) return 1;
return naturalCompare(a.title, b.title);
if (a.title.toLowerCase() === lowerSearch) return -1;
if (b.title.toLowerCase() === lowerSearch) return 1;
return collator.compare(a.title, b.title);
});
}, [search, options]);
}, [search, options, collatorLocale]);
const canAdd = (
!!onAddItem
@ -254,6 +257,8 @@ const SearchResult: React.FC<SearchResultProps> = ({
<View style={[styles.optionContent, selected && styles.optionContentSelected]}>
{icon}
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.optionLabel}
>{text}</Text>
</View>
@ -458,10 +463,11 @@ const useInputEventHandlers = ({
} else if (key === 'ArrowUp') {
selectedIndexControl.onPreviousResult();
event.preventDefault();
} else if (key === 'Enter') {
} else if (key === 'Enter' && Platform.OS === 'web') {
// This case is necessary on web to prevent the
// search input from becoming defocused after
// pressing "enter".
// pressing "enter". Enter key behavior is handled
// elsewhere for other platforms.
event.preventDefault();
onSubmit();
setSearch('');
@ -584,6 +590,7 @@ const ComboBox: React.FC<Props> = ({
onChangeText={setSearch}
onKeyPress={onKeyPress}
onSubmitEditing={onSubmit}
submitBehavior='submit'
placeholder={placeholder}
aria-activedescendant={showSearchResults ? activeId : undefined}
aria-controls={`menuBox-${baseId}`}

View File

@ -1,7 +1,8 @@
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 { _ } from '@joplin/lib/locale';
import { EdgeInsets, SafeAreaInsetsContext } from 'react-native-safe-area-context';
type ValueType = string;
export interface DropdownListItem {
@ -56,25 +57,43 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
};
}
private updateHeaderCoordinates = () => {
private updateHeaderCoordinates = (insets: EdgeInsets) => {
if (!this.headerRef) return;
// https://stackoverflow.com/questions/30096038/react-native-getting-the-position-of-an-element
this.headerRef.measure((_fx, _fy, width, height, px, py) => {
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) {
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
// onLayout can be inaccurate in some cases (in the past, this had caused the menu to be
// drawn far offscreen).
this.updateHeaderCoordinates();
this.updateHeaderCoordinates(insets);
this.setState({ listVisible: true });
};
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 itemHeight = 60;
const windowHeight = Dimensions.get('window').height - 50;
const windowHeight = Dimensions.get('window').height - 50 - offsetHeight;
const windowWidth = Dimensions.get('window').width;
// 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={{ flexDirection: 'row', flex: 1, alignItems: 'center' }}
onLayout={this.updateHeaderCoordinates}
onLayout={() => this.updateHeaderCoordinates(insets)}
ref={ref => { this.headerRef = ref; } }
>
<TouchableOpacity
style={headerWrapperStyle}
disabled={this.props.disabled}
onPress={this.onOpenList}
onPress={() => this.onOpenList(insets)}
accessibilityRole='button'
accessibilityHint={[this.props.accessibilityHint, _('Opens dropdown')].join(' ')}
>
@ -268,6 +293,14 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
</View>
);
}
public render() {
return (
<SafeAreaInsetsContext.Consumer>
{(insets) => this.renderWithInsets(insets)}
</SafeAreaInsetsContext.Consumer>
);
}
}
export default Dropdown;

View File

@ -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();
}
});
});

View File

@ -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);

View File

@ -87,6 +87,14 @@ const IconButton = (props: ButtonProps) => {
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 = (
<Pressable
ref={props.pressableRef}
@ -115,11 +123,7 @@ const IconButton = (props: ButtonProps) => {
opacity: fadeAnim,
...props.contentWrapperStyle,
}}>
<Icon
name={props.iconName}
style={props.iconStyle}
accessibilityLabel={null}
/>
{icon}
</Animated.View>
</Pressable>
);

View File

@ -84,7 +84,7 @@ const NestableFlatList = function<T>({
}, []);
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 startIndex = Math.max(0, visibleStartIndex - bufferSize);
const maximumIndex = data.length - 1;

View File

@ -16,6 +16,12 @@ import { ResourceInfo } from './hooks/useRerenderHandler';
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
import TestProviderStack from '../testing/TestProviderStack';
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 {
noteBody: string;
@ -28,7 +34,7 @@ interface WrapperProps {
const emptyObject = {};
const emptyArray: string[] = [];
const noOpFunction = () => {};
const testStore = createMockReduxStore();
let testStore: Store;
const WrappedNoteViewer: React.FC<WrapperProps> = (
{
noteBody,
@ -58,10 +64,34 @@ const getNoteViewerDom = async () => {
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', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
testStore = createMockReduxStore();
mockPluginServiceSetup(testStore);
});
afterEach(() => {
@ -85,6 +115,17 @@ describe('NoteBodyViewer', () => {
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([
{ keywords: ['match'], body: 'A match and another match. Both should be highlighted.', expectedMatchCount: 2 },
{ keywords: ['test'], body: 'No match.', expectedMatchCount: 0 },

View File

@ -230,8 +230,8 @@ const useEditorControl = (
setSearchState: setSearchStateCallback,
},
onResourceDownloaded: (id: string) => {
editorRef.current.onResourceDownloaded(id);
onResourceChanged: (id: string) => {
editorRef.current.onResourceChanged(id);
},
remove: () => {
@ -342,10 +342,18 @@ function NoteEditor(props: Props) {
const isDownloaded = (resourceInfos: ResourceInfos, resourceId: string) => {
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) {
const wasDownloaded = isDownloaded(lastNoteResources.current, key);
if (!wasDownloaded && isDownloaded(props.noteResources, key)) {
editorControl.onResourceDownloaded(key);
const hasDownloaded = !wasDownloaded && isDownloaded(props.noteResources, key);
const wasEncrypted = isEncrypted(lastNoteResources.current, key);
const hasDecrypted = wasEncrypted && !isEncrypted(props.noteResources, key);
if (hasDownloaded || hasDecrypted) {
editorControl.onResourceChanged(key);
}
}
}, [props.noteResources, editorControl]);

View File

@ -257,7 +257,7 @@ describe('RichTextEditor', () => {
ref={editorRef}
/>,
);
editorRef.current.onResourceDownloaded(localResource.id);
editorRef.current.onResourceChanged(localResource.id);
expect(
await findElement(`img[data-resource-id=${JSON.stringify(localResource.id)}]`),
@ -452,6 +452,8 @@ describe('RichTextEditor', () => {
'==highlight==ed',
'<sup>Super</sup>script',
'<sub>Sub</sub>script',
'![image](data:image/svg+xml;utf8,test)',
'<img src="data:image/svg+xml;utf8,test" width="120">',
])('should preserve inline markup on edit (case %#)', async (initialBody) => {
initialBody += 'test'; // Ensure that typing will add new content outside the formatting
let body = initialBody;

View File

@ -37,6 +37,7 @@ const useStyles = (themeId: number) => {
const listItemPressable: ViewStyle = {
flexGrow: 1,
flexShrink: 1,
alignSelf: 'stretch',
};
const listItemPressableWithCheckbox: ViewStyle = {

View File

@ -7,6 +7,8 @@ import AccessibleView from '../accessibility/AccessibleView';
import debounce from '../../utils/debounce';
import FocusControl from '../accessibility/FocusControl/FocusControl';
import { ModalState } from '../accessibility/FocusControl/types';
import useKeyboardState from '../../utils/hooks/useKeyboardState';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface MenuOptionDivider {
isDivider: true;
@ -29,7 +31,9 @@ interface Props {
}
const useStyles = (themeId: number) => {
const { height: windowHeight } = useWindowDimensions();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const safeAreaInsets = useSafeAreaInsets();
const { dockedKeyboardHeight: keyboardHeight } = useKeyboardState();
return useMemo(() => {
const theme = themeStyle(themeId);
@ -46,6 +50,20 @@ const useStyles = (themeId: number) => {
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({
divider: {
borderBottomWidth: 1,
@ -66,13 +84,13 @@ const useStyles = (themeId: number) => {
opacity: 0.5,
},
menuContentScroller: {
maxHeight: windowHeight - 50,
maxHeight: maxMenuHeight,
},
contextMenuButton: {
padding: 0,
},
});
}, [themeId, windowHeight]);
}, [themeId, windowWidth, windowHeight, safeAreaInsets, keyboardHeight]);
};
const MenuComponent: React.FC<Props> = props => {

View File

@ -1,12 +1,15 @@
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 IconButton from '../IconButton';
import { _ } from '@joplin/lib/locale';
import { useCallback, useState } from 'react';
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
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 {
wrapperStyle: ViewStyle;
@ -15,10 +18,24 @@ interface Props {
}
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 [dialogVisible, setDialogVisible] = useState(false);
@ -31,6 +48,10 @@ const WebBetaButton: React.FC<Props> = props => {
setDialogVisible(false);
}, []);
const renderParagraph = (content: string) => {
return <Text variant='bodyLarge' style={styles.paragraph}>{content}</Text>;
};
return (
<>
<IconButton
@ -49,10 +70,13 @@ const WebBetaButton: React.FC<Props> = props => {
visible={dialogVisible}
onDismiss={onHideDialog}
>
<Text>{'At present, the web client is in beta. In the future, it may change significantly, or be removed.'}</Text>
<View style={feedbackContainerStyles}>
{renderParagraph('Welcome to the beta version of the Joplin Web App!')}
{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={() => NavService.go('DocumentScanner')}>{'Test work-in-progress feature: Document scanner'}</LinkButton>
</View>
</DismissibleDialog>
</>

View File

@ -89,7 +89,7 @@ describe('TagEditor', () => {
const searchResult = screen.getByRole('button', { name: 'new tag 1' });
fireEvent.press(searchResult);
expect(currentTags).toEqual(['test', 'new tag 1']);
expect(currentTags).toEqual(['new tag 1', 'test']);
// Manually unmount to prevent warnings
unmount();
@ -115,7 +115,7 @@ describe('TagEditor', () => {
const addNewButton = screen.getByRole('button', { name: 'Add new' });
fireEvent.press(addNewButton);
expect(currentTags).toEqual(['test', 'create']);
expect(currentTags).toEqual(['create', 'test']);
unmount();
});

View File

@ -10,6 +10,7 @@ import { TagEntity } from '@joplin/lib/services/database/types';
import { Divider } from 'react-native-paper';
import focusView from '../utils/focusView';
import { msleep } from '@joplin/utils/time';
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
export enum TagEditorMode {
Large,
@ -38,11 +39,13 @@ const useStyles = (themeId: number, headerStyle: TextStyle|undefined) => {
color: theme.color3,
flexDirection: 'row',
alignItems: 'center',
maxWidth: '100%',
gap: 4,
},
tagText: {
color: theme.color3,
fontSize: theme.fontSize,
flexShrink: 1,
},
removeTagButton: {
color: theme.color3,
@ -122,7 +125,11 @@ const TagCard: React.FC<TagChipProps> = props => {
style={props.styles.tag}
role='listitem'
>
<Text style={props.styles.tagText}>{props.title}</Text>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={props.styles.tagText}
>{props.title}</Text>
<IconButton
pressableRef={removeButtonRef}
themeId={props.themeId}
@ -144,23 +151,32 @@ interface TagsBoxProps {
}
const TagsBox: React.FC<TagsBoxProps> = props => {
const collatorLocale = getCollatorLocale();
const collator = useMemo(() => {
return getCollator(collatorLocale);
}, [collatorLocale]);
const onRemoveTag = useCallback((tag: string) => {
props.onRemoveTag(tag);
}, [props.onRemoveTag]);
const renderContent = () => {
if (props.tags.length) {
return props.tags.map(tag => (
<TagCard
key={`tag-${tag}`}
title={tag}
styles={props.styles}
themeId={props.themeId}
onRemove={onRemoveTag}
autofocus={props.autofocusTag === tag}
onAutoFocusComplete={props.onAutoFocusComplete}
/>
));
return props.tags
.sort((a, b) => {
return collator.compare(a, b);
})
.map(tag => (
<TagCard
key={`tag-${tag}`}
title={tag}
styles={props.styles}
themeId={props.themeId}
onRemove={onRemoveTag}
autofocus={props.autofocusTag === tag}
onAutoFocusComplete={props.onAutoFocusComplete}
/>
));
} else {
return <Text
style={props.styles.noTagsLabel}
@ -189,15 +205,13 @@ const TagsBox: React.FC<TagsBoxProps> = props => {
</View>;
};
const normalizeTag = (tagText: string) => tagText.trim().toLowerCase();
const TagEditor: React.FC<Props> = props => {
const styles = useStyles(props.themeId, props.headerStyle);
const comboBoxItems = useMemo(() => {
return props.allTags
// 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 => {
const title = tag.title ?? 'Untitled';
return {
@ -217,11 +231,13 @@ const TagEditor: React.FC<Props> = props => {
const onAddTag = useCallback((title: string) => {
AccessibilityInfo.announceForAccessibility(_('Added tag: %s', title));
props.onTagsChange([...props.tags, normalizeTag(title)]);
props.onTagsChange([...props.tags, title.trim()]);
}, [props.tags, props.onTagsChange]);
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];
setAutofocusTag(targetTag);
@ -229,7 +245,7 @@ const TagEditor: React.FC<Props> = props => {
// prevent focus from occasionally jumping away from the tag box.
await msleep(100);
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]);
const onComboBoxSelect = useCallback((item: { title: string }) => {
@ -237,16 +253,16 @@ const TagEditor: React.FC<Props> = props => {
return { willRemove: true };
}, [onAddTag]);
const allTagsSet = useMemo(() => {
const allTagsSetNormalized = useMemo(() => {
return new Set([
...props.allTags.map(tag => tag.title),
...props.tags,
...props.allTags.map(tag => tag.title?.trim()?.toLowerCase()),
...props.tags.map(tag => tag.trim().toLowerCase()),
]);
}, [props.allTags, props.tags]);
const onCanAddTag = useCallback((tag: string) => {
return !allTagsSet.has(normalizeTag(tag));
}, [allTagsSet]);
return !allTagsSetNormalized.has(tag.trim().toLowerCase());
}, [allTagsSetNormalized]);
const showAssociatedTags = props.mode === TagEditorMode.Large || props.tags.length > 0;

View File

@ -8,6 +8,7 @@ import { themeStyle } from './global-style';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import useKeyboardState from '../utils/hooks/useKeyboardState';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import FeedbackBanner from './FeedbackBanner';
interface Props {
// 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} />
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
{!notesScreenVisible && !searchScreenVisible && <Screen navigation={{ state: route }} themeId={props.themeId} dispatch={props.dispatch} />}
{notesScreenVisible ? <FeedbackBanner/> : null}
<View style={{ height: autocompletionBarPadding }} />
</KeyboardAvoidingView>
);

View File

@ -6,11 +6,12 @@ import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
import PluginRunnerWebView from './PluginRunnerWebView';
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 getWebViewDomById from '../../utils/testing/getWebViewDomById';
import Setting from '@joplin/lib/models/Setting';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import CommandService from '@joplin/lib/services/CommandService';
let store: Store<AppState>;
@ -30,6 +31,16 @@ const defaultManifestProperties = {
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', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
@ -56,16 +67,68 @@ describe('PluginRunnerWebView', () => {
`,
});
render(<WrappedPluginRunnerWebView/>);
// Should load the plugin
await waitFor(async () => {
expect(PluginService.instance().pluginById(testPlugin.manifest.id)).toBeTruthy();
});
await waitForPluginToLoad(testPlugin);
// Should show the dialog
await waitFor(async () => {
const dom = await getWebViewDomById('joplin__PluginDialogWebView');
const dom = await getUserWebViewDom();
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();
});
});

View File

@ -120,7 +120,7 @@ const PluginPanelViewer: React.FC<Props> = props => {
}
return (
<View style={styles.webViewContainer}>
<View style={styles.webViewContainer} testID='plugin-tab-content'>
<PluginUserWebView
key={selectedTabId}
themeId={props.themeId}

View File

@ -4,7 +4,6 @@ import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, switch
import { act, fireEvent, render, screen, userEvent, waitFor } from '../../../../utils/testing/testingLibrary';
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
import pluginServiceSetup from './testUtils/pluginServiceSetup';
import { writeFile } from 'fs-extra';
import { join } from 'path';
import shim from '@joplin/lib/shim';
@ -15,6 +14,7 @@ import createMockReduxStore from '../../../../utils/testing/createMockReduxStore
import WrappedPluginStates from './testUtils/WrappedPluginStates';
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
import Setting from '@joplin/lib/models/Setting';
import mockPluginServiceSetup from '../../../../utils/testing/mockPluginServiceSetup';
let reduxStore: Store<AppState> = null;
@ -56,7 +56,7 @@ describe('PluginStates.installed', () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
reduxStore = createMockReduxStore();
pluginServiceSetup(reduxStore);
mockPluginServiceSetup(reduxStore);
resetRepoApi();
await mockMobilePlatform('android');

View File

@ -3,13 +3,13 @@ import { mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '
import { render, screen, userEvent, waitFor } from '../../../../utils/testing/testingLibrary';
import pluginServiceSetup from './testUtils/pluginServiceSetup';
import createMockReduxStore from '../../../../utils/testing/createMockReduxStore';
import WrappedPluginStates from './testUtils/WrappedPluginStates';
import { AppState } from '../../../../utils/types';
import { Store } from 'redux';
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
import { resetRepoApi } from './utils/useRepoApi';
import mockPluginServiceSetup from '../../../../utils/testing/mockPluginServiceSetup';
const expectSearchResultCountToBe = async (count: number) => {
await waitFor(() => {
@ -37,7 +37,7 @@ describe('PluginStates.search', () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
reduxStore = createMockReduxStore();
pluginServiceSetup(reduxStore);
mockPluginServiceSetup(reduxStore);
mockMobilePlatform('android');
resetRepoApi();

View File

@ -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

View File

@ -73,6 +73,7 @@ import { defaultWindowId } from '@joplin/lib/reducer';
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
import { SelectionRange } from '../../../contentScripts/markdownEditorBundle/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
const emptyArray: any[] = [];
@ -148,6 +149,7 @@ interface State {
};
showSpeechToTextDialog: boolean;
multiline: boolean;
}
class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> implements BaseNoteScreenComponent<State> {
@ -219,6 +221,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
},
showSpeechToTextDialog: false,
multiline: false,
};
this.titleTextFieldRef = React.createRef();
@ -508,7 +511,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
flexDirection: 'row',
flexBasis: 'auto',
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
borderBottomColor: theme.dividerColor,
borderBottomWidth: 1,
};
@ -528,6 +530,16 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
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);
return this.styles_[cacheKey];
}
@ -701,7 +713,8 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
}
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 });
}
@ -1172,69 +1185,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
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() {
const note = this.state.note;
const isTodo = note && !!note.is_todo;
@ -1726,6 +1676,15 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
placeholder={_('Add title')}
placeholderTextColor={theme.colorFaded}
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 File

@ -1,6 +1,6 @@
import * as React from 'react';
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 useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import Revision from '@joplin/lib/models/Revision';
@ -102,15 +102,12 @@ const useStyles = (themeId: number) => {
root: {
...theme.rootStyle,
},
titleContainer: {
titleViewContainer: {
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
borderTopColor: theme.dividerColor,
borderTopWidth: 1,
borderBottomColor: theme.dividerColor,
borderBottomWidth: 1,
},
titleViewContainer: {
flex: 0,
flexDirection: 'row',
flexBasis: 'auto',
@ -139,6 +136,7 @@ const NoteRevisionViewer: React.FC<Props> = props => {
const { note, resources } = useRevisionNote(revisions, currentRevisionId);
const [initialScroll, setInitialScroll] = useState(0);
const [hasRevisions, setHasRevisions] = useState(false);
const [multiline, setMultiline] = useState(false);
const options = useMemo(() => {
const result = [];
@ -201,6 +199,9 @@ const NoteRevisionViewer: React.FC<Props> = props => {
const onHelpPress = useCallback(() => {
void dialogs.info(helpMessageText);
}, [helpMessageText, dialogs]);
const onToggleTitlePress = useCallback(() => {
void setMultiline(!multiline);
}, [multiline]);
const styles = useStyles(props.themeId);
const dropdownLabelText = _('Revision:');
@ -213,13 +214,20 @@ const NoteRevisionViewer: React.FC<Props> = props => {
);
const titleComponent = (
<SafeAreaView style={styles.titleContainer}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.titleViewContainer}>
<Text style={styles.titleText}>{note?.title ?? ''}</Text>
</View>
</ScrollView>
</SafeAreaView>
<View style={styles.titleViewContainer}>
<TextInput
style={styles.titleText}
value={note?.title ?? ''}
editable={false}
multiline={multiline}
/>
<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}>

View File

@ -11,6 +11,7 @@ import { TagEntity } from '@joplin/lib/services/database/types';
import { useCallback, useMemo, useState } from 'react';
import { Dispatch } from 'redux';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
interface Props {
dispatch: Dispatch;
@ -46,13 +47,17 @@ const useStyles = (themeId: number) => {
const TagsScreenComponent: React.FC<Props> = props => {
const [tags, setTags] = useState<TagEntity[]>([]);
const styles = useStyles(props.themeId);
const collatorLocale = getCollatorLocale();
const collator = useMemo(() => {
return getCollator(collatorLocale);
}, [collatorLocale]);
type TagItemPressEvent = { id: string };
useAsyncEffect(async () => {
const tags = await Tag.allWithNotes();
tags.sort((a, b) => {
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
return collator.compare(a.title, b.title);
});
setTags(tags);
}, []);

View File

@ -5,9 +5,6 @@ import { _, languageName } from '@joplin/lib/locale';
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
import VoiceTyping, { OnTextCallback, VoiceTypingSession } from '../../services/voiceTyping/VoiceTyping';
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 RecordingControls from './RecordingControls';
import { PrimaryButton } from '../buttons';
@ -16,19 +13,17 @@ import shim from '@joplin/lib/shim';
interface Props {
locale: string;
provider: string;
onDismiss: ()=> void;
onText: (text: string)=> void;
}
interface UseVoiceTypingProps {
locale: string;
provider: string;
onSetPreview: OnTextCallback;
onText: OnTextCallback;
}
const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypingProps) => {
const useVoiceTyping = ({ locale, onSetPreview, onText }: UseVoiceTypingProps) => {
const [voiceTyping, setVoiceTyping] = useState<VoiceTypingSession>(null);
const [error, setError] = useState<Error|null>(null);
const [mustDownloadModel, setMustDownloadModel] = useState<boolean | null>(null);
@ -43,8 +38,8 @@ const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypi
voiceTypingRef.current = voiceTyping;
const builder = useMemo(() => {
return new VoiceTyping(locale, provider?.startsWith('whisper') ? [whisper] : [vosk]);
}, [locale, provider]);
return new VoiceTyping(locale, [whisper]);
}, [locale]);
const [redownloadCounter, setRedownloadCounter] = useState(0);
@ -121,7 +116,6 @@ const SpeechToTextComponent: React.FC<Props> = props => {
locale: props.locale,
onSetPreview: setPreview,
onText: props.onText,
provider: props.provider,
});
useEffect(() => {
@ -209,6 +203,4 @@ const SpeechToTextComponent: React.FC<Props> = props => {
/>;
};
export default connect((state: AppState) => ({
provider: state.settings['voiceTyping.preferredProvider'],
}))(SpeechToTextComponent);
export default SpeechToTextComponent;

View File

@ -5,6 +5,7 @@ import { EditorProcessApi, EditorProps, EditorWithParentProps, ExportedWebViewGl
import readFileToBase64 from '../utils/readFileToBase64';
import { EditorControl } from '@joplin/editor/types';
import { EditorEventType } from '@joplin/editor/events';
import InMemoryCache from '@joplin/renderer/InMemoryCache';
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)})`);
}
// resolveImageSrc can be called frequently for the same image. To avoid unnecessary IPC,
// use an InMemoryCache.
const resolvedImageSrcCache = new InMemoryCache();
const control = createEditor(parentElement, {
initialText,
initialNoteId,
@ -68,8 +73,16 @@ export const createEditorWithParent = ({
allEditors = allEditors.filter(other => other !== control);
}
},
resolveImageSrc: (src) => {
return messenger.remoteApi.onResolveImageSrc(src);
resolveImageSrc: async (src, reloadCounter) => {
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;
},
});

View File

@ -37,5 +37,5 @@ export interface MainProcessApi {
onEditorAdded(): Promise<void>;
logMessage(message: string): Promise<void>;
onPasteFile(type: string, dataBase64: string): Promise<void>;
onResolveImageSrc(src: string): Promise<string|null>;
onResolveImageSrc(src: string, reloadCounter: number): Promise<string|null>;
}

View File

@ -130,7 +130,7 @@ const useWebViewSetup = ({
async onEditorAdded() {
messenger.remoteApi.updatePlugins(codeMirrorPluginsRef.current);
},
async onResolveImageSrc(src) {
async onResolveImageSrc(src, reloadCounter) {
const url = parseResourceUrl(src);
if (!url.itemId) return null;
const item = await Resource.load(url.itemId);
@ -144,7 +144,8 @@ const useWebViewSetup = ({
}
return null;
} else {
return Resource.fullPath(item);
const path = Resource.fullPath(item);
return reloadCounter ? `${path}?r=${reloadCounter}` : path;
}
},
};

View File

@ -44,11 +44,14 @@ const useMessenger = (props: UseMessengerProps) => {
onAttachRef.current = props.onAttachFile;
const markupRenderingSettings = useRef<RenderOptions>(null);
const baseTheme = props.settings.themeData;
markupRenderingSettings.current = {
themeId: props.themeId,
highlightedKeywords: [],
resources: props.noteResources,
themeOverrides: {},
themeOverrides: {
noteViewerFontSize: `${baseTheme.fontSize}${baseTheme.fontSizeUnits ?? 'px'}`,
},
noteHash: '',
initialScroll: 0,
pluginAssetContainerSelector: null,

View File

@ -1,3 +1,4 @@
import './utils/polyfills';
import { AppRegistry } from 'react-native';
import Root from './root';
import Setting from '@joplin/lib/models/Setting';

View File

@ -535,7 +535,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 144;
CURRENT_PROJECT_VERSION = 145;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@ -544,7 +544,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.4.2;
MARKETING_VERSION = 13.4.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@ -570,7 +570,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 144;
CURRENT_PROJECT_VERSION = 145;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
@ -578,7 +578,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.4.2;
MARKETING_VERSION = 13.4.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@ -771,7 +771,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 144;
CURRENT_PROJECT_VERSION = 145;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@ -782,7 +782,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.4.2;
MARKETING_VERSION = 13.4.3;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@ -814,7 +814,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 144;
CURRENT_PROJECT_VERSION = 145;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@ -825,7 +825,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.4.2;
MARKETING_VERSION = 13.4.3;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",

View File

@ -58,13 +58,13 @@
"react-native-file-viewer": "2.1.5",
"react-native-fs": "2.20.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-modal-datetime-picker": "18.0.0",
"react-native-paper": "5.13.5",
"react-native-popup-menu": "0.17.0",
"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-safe-area-context": "5.4.1",
"react-native-securerandom": "1.0.1",
@ -73,7 +73,6 @@
"react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.2.0",
"react-native-version-info": "1.1.1",
"react-native-vosk": "0.1.12",
"react-native-webview": "13.13.5",
"react-native-zip-archive": "7.0.1",
"react-redux": "8.1.3",
@ -94,19 +93,19 @@
"@joplin/tools": "~3.4",
"@joplin/turndown": "~4.0.80",
"@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",
"@react-native-community/cli": "16.0.3",
"@react-native-community/cli-platform-android": "16.0.3",
"@react-native-community/cli-platform-ios": "16.0.3",
"@react-native/babel-preset": "0.79.2",
"@react-native/metro-config": "0.79.2",
"@react-native/babel-preset": "0.79.3",
"@react-native/metro-config": "0.79.3",
"@react-native/typescript-config": "0.79.2",
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
"@testing-library/react-native": "13.2.0",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.103",
"@types/node": "18.19.111",
"@types/react": "19.0.14",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.135",
@ -115,7 +114,7 @@
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.20.0",
"esbuild": "0.25.4",
"esbuild": "0.25.5",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.2.0",
"gulp": "4.0.2",

View File

@ -14,7 +14,7 @@ import NoteScreen from './components/screens/Note/Note';
import UpgradeSyncTargetScreen from './components/screens/UpgradeSyncTargetScreen';
import Setting, { } from '@joplin/lib/models/Setting';
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 handleShared from './utils/shareHandler';
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;
import SafeAreaView from './components/SafeAreaView';
const { connect, Provider } = require('react-redux');
import fastDeepEqual = require('fast-deep-equal');
import { Provider as PaperProvider, MD3DarkTheme, MD3LightTheme } from 'react-native-paper';
import BackButtonService, { BackButtonHandler } from './services/BackButtonService';
import NavService from '@joplin/lib/services/NavService';
@ -95,7 +94,6 @@ import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTh
import PluginRunnerWebView from './components/plugins/PluginRunnerWebView';
import { refreshFolders, scheduleRefreshFolders } from '@joplin/lib/folders-screen-utils';
import ShareManager from './components/screens/ShareManager';
import appDefaultState from './utils/appDefaultState';
import { setDateFormat, setTimeFormat, setTimeLocale } from '@joplin/utils/time';
import DialogManager from './components/DialogManager';
import { AppState } from './utils/types';
@ -108,6 +106,7 @@ import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
import DocumentScanner from './components/screens/DocumentScanner/DocumentScanner';
import buildStartupTasks from './utils/buildStartupTasks';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import appReducer from './utils/appReducer';
const logger = Logger.create('root');
const perfLogger = PerformanceLogger.create();
@ -235,204 +234,6 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
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));
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
return (
<FocusControl.Provider>
<PaperProvider theme={{
...paperTheme,
version: 3,
colors: {
...paperTheme.colors,
onPrimaryContainer: theme.color5,
primaryContainer: theme.backgroundColor5,
<MenuProvider
style={{ flex: 1 }}
closeButtonLabel={_('Dismiss')}
>
<PaperProvider theme={{
...paperTheme,
version: 3,
colors: {
...paperTheme.colors,
onPrimaryContainer: theme.color5,
primaryContainer: theme.backgroundColor5,
outline: theme.codeBorderColor,
outline: theme.codeBorderColor,
primary: theme.color4,
onPrimary: theme.backgroundColor4,
primary: theme.color4,
onPrimary: theme.backgroundColor4,
background: theme.backgroundColor,
background: theme.backgroundColor,
surface: theme.backgroundColor,
onSurface: theme.color,
surface: theme.backgroundColor,
onSurface: theme.color,
secondaryContainer: theme.raisedBackgroundColor,
onSecondaryContainer: theme.raisedColor,
secondaryContainer: theme.raisedBackgroundColor,
onSecondaryContainer: theme.raisedColor,
surfaceVariant: theme.backgroundColor3,
onSurfaceVariant: theme.color3,
surfaceVariant: theme.backgroundColor3,
onSurfaceVariant: theme.color3,
elevation: {
level0: 'transparent',
level1: theme.oddBackgroundColor,
level2: theme.raisedBackgroundColor,
level3: theme.raisedBackgroundColor,
level4: theme.raisedBackgroundColor,
level5: theme.raisedBackgroundColor,
elevation: {
level0: 'transparent',
level1: theme.oddBackgroundColor,
level2: theme.raisedBackgroundColor,
level3: theme.raisedBackgroundColor,
level4: theme.raisedBackgroundColor,
level5: theme.raisedBackgroundColor,
},
},
},
}}>
<DialogManager themeId={this.props.themeId}>
<StatusBar barStyle={statusBarStyle} />
<MenuProvider
style={{ flex: 1 }}
closeButtonLabel={_('Dismiss')}
>
}}>
<DialogManager themeId={this.props.themeId}>
<StatusBar barStyle={statusBarStyle} />
<SafeAreaProvider>
<FocusControl.MainAppContent style={{ flex: 1 }}>
{shouldShowMainContent ? mainContent : (
@ -1028,9 +829,9 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
)}
</FocusControl.MainAppContent>
</SafeAreaProvider>
</MenuProvider>
</DialogManager>
</PaperProvider>
</DialogManager>
</PaperProvider>
</MenuProvider>
</FocusControl.Provider>
);
}

View File

@ -1,46 +1,43 @@
import { RSA } from '@joplin/lib/services/e2ee/types';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import buildRsaCryptoProvider from '@joplin/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider';
import { WebCryptoSlice } from '@joplin/lib/services/e2ee/ppk/webCrypto/WebCryptoRsa';
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;
interface RSAKeyPair {
interface LegacyRsaKeyPair {
public: string;
private: string;
keySizeBits: number;
}
const logger = Logger.create('RSA');
const rsa: RSA = {
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);
const legacyRsa: PublicKeyCrypto = {
generateKeyPair: async () => {
const keySize = 2048;
const keys: LegacyRsaKeyPair = await RnRSA.generateKeys(keySize);
// Sanity check
if (!keys.private) throw new Error('No private 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 };
},
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.
return RnRSA.encrypt(plaintextUtf8, rsaKeyPair.public);
return Buffer.from(await RnRSA.encrypt(plaintextUtf8, rsaKeyPair.public), 'base64');
},
decrypt: async (ciphertextBase64: string, rsaKeyPair: RSAKeyPair): Promise<string> => {
const ciphertextBuffer = Buffer.from(ciphertextBase64, 'base64');
decrypt: async (ciphertextBuffer: CiphertextBuffer, rsaKeyPair: LegacyRsaKeyPair): Promise<string> => {
const maximumEncryptedSize = Math.floor(rsaKeyPair.keySizeBits / 8); // Usually 256
// On iOS, .decrypt fails without throwing or rejecting.
@ -75,20 +72,26 @@ const rsa: RSA = {
}
return result.join('');
} else {
const plainText = await RnRSA.decrypt(ciphertextBase64, rsaKeyPair.private);
const plainText = await RnRSA.decrypt(ciphertextBuffer.toString('base64'), rsaKeyPair.private);
handleError(plainText);
return plainText;
}
},
publicKey: (rsaKeyPair: RSAKeyPair): string => {
publicKey: async (rsaKeyPair: LegacyRsaKeyPair) => {
return rsaKeyPair.public;
},
privateKey: (rsaKeyPair: RSAKeyPair): string => {
privateKey: async (rsaKeyPair: LegacyRsaKeyPair) => {
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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -67,10 +67,10 @@ import MigrationService from '@joplin/lib/services/MigrationService';
import { clearSharedFilesCache } from '../utils/ShareUtils';
import setIgnoreTlsErrors from '../utils/TlsUtils';
import ShareService from '@joplin/lib/services/share/ShareService';
import { loadMasterKeysFromSettings, migrateMasterPassword } from '@joplin/lib/services/e2ee/utils';
import { setRSA } from '@joplin/lib/services/e2ee/ppk';
import { loadMasterKeysFromSettings, migrateMasterPassword, migratePpk } from '@joplin/lib/services/e2ee/utils';
import { setRSA } from '@joplin/lib/services/e2ee/ppk/ppk';
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 { getCurrentProfile } from '@joplin/lib/services/profileConfig';
import { getDatabaseName, getPluginDataDir, getProfilesRootDir, getResourceDir } from '../services/profiles';
@ -356,6 +356,9 @@ const buildStartupTasks = (
addTask('buildStartupTasks/set up sharing', async () => {
await ShareService.instance().initialize(store, EncryptionService.instance());
});
addTask('buildStartupTasks/migrate PPK', async () => {
await migratePpk();
});
addTask('buildStartupTasks/load folders', async () => {
await refreshFolders(dispatch, '');
@ -463,11 +466,7 @@ const buildStartupTasks = (
// just print some messages in the console.
// ----------------------------------------------------------------------------
if (Setting.value('env') === 'dev') {
if (Platform.OS !== 'web') {
await runRsaIntegrationTests();
} else {
logger.info('Skipping encryption tests -- not supported on web.');
}
await runRsaIntegrationTests();
await runCryptoIntegrationTests();
await runOnDeviceFsDriverTests();
}

View File

@ -236,15 +236,20 @@ export class WorkerApi {
const folderName = removeReservedWords(basename(path));
let handle: FileSystemDirectoryHandle;
try {
handle = await parent.getDirectoryHandle(folderName, { create });
this.directoryHandleCache_.set(path, handle);
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
if (!parent) {
logger.debug('Parent not found for path', path);
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;

View File

@ -10,13 +10,16 @@ const useKeyboardState = () => {
const showListener = Keyboard.addListener('keyboardDidShow', (evt) => {
setKeyboardVisible(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', () => {
setKeyboardVisible(false);
setKeyboardHeight(0);
});
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;
// 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

View File

@ -0,0 +1,2 @@
// Only some of the React Native polyfills should be used on Web:
import './bufferPolyfill';

View File

@ -1,15 +1,16 @@
import reducer from '@joplin/lib/reducer';
import { createStore } from 'redux';
import appDefaultState from '../appDefaultState';
import Setting from '@joplin/lib/models/Setting';
import { AppState } from '../types';
import appReducer from '../appReducer';
const testReducer = (state: AppState|undefined, action: unknown): AppState => {
state ??= {
...appDefaultState,
settings: Setting.toPlainObject(),
};
return { ...state, ...reducer(state, action) };
return { ...state, ...appReducer(state, action) };
};
const createMockReduxStore = () => {

View File

@ -7,11 +7,11 @@ class MockPluginRunner extends BasePluginRunner {
public override async stop() {}
}
const pluginServiceSetup = (store: Store) => {
const mockPluginServiceSetup = (store: Store) => {
const runner = new MockPluginRunner();
PluginService.instance().initialize(
'2.14.0', { joplin: {} }, runner, store,
);
};
export default pluginServiceSetup;
export default mockPluginServiceSetup;

View File

@ -13,6 +13,7 @@ import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectio
import getSearchState from './utils/getSearchState';
import { noteIdFacet, setNoteIdEffect } from './extensions/selectedNoteIdExtension';
import jumpToHash from './editorCommands/jumpToHash';
import { resetImageResourceEffect } from './extensions/rendering/renderBlockImages';
interface Callbacks {
onUndoRedo(): void;
@ -229,8 +230,12 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
};
}
public onResourceDownloaded(_id: string) {
// Unused
public onResourceChanged(id: string) {
this.editor.dispatch({
effects: [
resetImageResourceEffect.of({ id }),
],
});
}
public setContentScripts(plugins: ContentScriptData[]) {

View File

@ -51,7 +51,7 @@ import ctrlClickCheckboxExtension from './extensions/ctrlClickCheckboxExtension'
type ExtendedEditorView = typeof EditorView & { EDIT_CONTEXT: boolean };
(EditorView as ExtendedEditorView).EDIT_CONTEXT = false;
export type ResolveImageCallback = (imageSrc: string)=> Promise<string>;
export type ResolveImageCallback = (imageSrc: string, reloadCounter: number)=> Promise<string>;
interface CodeMirrorProps {
resolveImageSrc: ResolveImageCallback;
@ -66,8 +66,8 @@ const createEditor = (
props.onLogMessage('Initializing CodeMirror...');
const context: RenderedContentContext = {
resolveImageSrc: (src) => {
return props.resolveImageSrc(src);
resolveImageSrc: (src, counter) => {
return props.resolveImageSrc(src, counter);
},
};

View File

@ -1,23 +1,39 @@
import { EditorSelection } from '@codemirror/state';
import createTestEditor from '../../testing/createTestEditor';
import renderBlockImages from './renderBlockImages';
import renderBlockImages, { resetImageResourceEffect, testing__resetImageRefreshCounterCache } from './renderBlockImages';
import { EditorView } from '@codemirror/view';
const createEditor = (initialMarkdown: string, hasImage: boolean) => {
const resolveImageSrc = jest.fn(src => Promise.resolve(src));
return createTestEditor(
const allowImageUrlsToBeFetched = async () => {
// Yield to the event loop. Since image URLs are fetched asynchronously, this is needed to
// 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,
EditorSelection.cursor(0),
hasImage ? ['Image'] : [],
[renderBlockImages({ resolveImageSrc })],
);
await allowImageUrlsToBeFetched();
return editor;
};
const findImage = (editor: EditorView) => {
return editor.dom.querySelector('div.cm-md-image > .image');
const findImages = (editor: EditorView) => {
return editor.dom.querySelectorAll<HTMLDivElement>('div.cm-md-image > .image');
};
const getImageBackgroundUrls = (editor: EditorView) => {
return [...findImages(editor)].map(image => image.style.backgroundImage);
};
describe('renderBlockImages', () => {
beforeEach(() => {
testing__resetImageRefreshCounterCache();
});
test.each([
{ spaceBefore: '', spaceAfter: '\n\n', alt: '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 }) => {
const editor = await createEditor(`${spaceBefore}![${alt}](:/0123456789abcdef0123456789abcdef)${spaceAfter}`, true);
const image = findImage(editor);
expect(image).toBeTruthy();
expect(image.role).toBe('image');
expect(image.ariaLabel).toBe(alt);
const images = findImages(editor);
expect(images).toHaveLength(1);
expect(images[0].role).toBe('image');
expect(images[0].ariaLabel).toBe(alt);
});
// 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.
test('should not render web images', async () => {
const editor = await createEditor('![test](https://example.com/test.png)\n\n', true);
const image = findImage(editor);
expect(image).toBeNull();
const images = findImages(editor);
expect(images).toHaveLength(0);
});
test('should allow reloading specific images', async () => {
const editor = await createEditor('![test](:/a123456789abcdef0123456789abcdef)\n![test 2](:/b123456789abcdef0123456789abcde2)', 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)',
]);
});
});

View File

@ -1,6 +1,6 @@
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { SyntaxNodeRef } from '@lezer/common';
import { EditorState } from '@codemirror/state';
import { EditorState, StateEffect, Transaction } from '@codemirror/state';
import { RenderedContentContext } from './types';
import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
@ -16,22 +16,21 @@ class ImageWidget extends WidgetType {
private readonly context_: RenderedContentContext,
private readonly src_: string,
private readonly alt_: string,
private readonly reloadCounter_ = 0,
) {
super();
}
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() {
const container = document.createElement('div');
container.classList.add(imageClassName);
public updateDOM(dom: HTMLElement): boolean {
const image = dom.querySelector<HTMLDivElement>('div.image');
if (!image) return false;
const image = document.createElement('div');
image.role = 'image';
image.ariaLabel = this.alt_;
image.classList.add('image');
image.role = 'image';
const updateImageUrl = () => {
if (this.resolvedSrc_) {
@ -43,14 +42,26 @@ class ImageWidget extends WidgetType {
if (!this.resolvedSrc_) {
void (async () => {
this.resolvedSrc_ = await this.context_.resolveImageSrc(this.src_);
this.resolvedSrc_ = await this.context_.resolveImageSrc(this.src_, this.reloadCounter_);
updateImageUrl();
})();
} else {
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);
this.updateDOM(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) => [
EditorView.theme({
[`& .${imageClassName} > div`]: {
@ -106,7 +127,7 @@ const renderBlockImages = (context: RenderedContentContext) => [
if (src) {
const isLastLine = lineTo.number === state.doc.lines;
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
// 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)];
},
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;
},
}),
];

View File

@ -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 { SyntaxNodeRef } from '@lezer/common';
@ -13,8 +13,11 @@ export interface ReplacementExtension {
// Disable the decoration when near the cursor. Defaults to true.
hideWhenContainsSelection?: boolean;
// Allows specifying custom logic to refresh all decorations associated with the extension
shouldFullReRender?: (transaction: Transaction)=> boolean;
}
export interface RenderedContentContext {
resolveImageSrc(src: string): Promise<string>;
resolveImageSrc(src: string, reloadCounter: number): Promise<string>;
}

View File

@ -72,7 +72,12 @@ const makeBlockReplaceExtension = (extensionSpec: ReplacementExtension) => {
decorations = decorations.map(transaction.changes);
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);
}

View File

@ -271,7 +271,7 @@ const createEditor = async (
setContentScripts: (_plugins: ContentScriptData[]) => {
throw new Error('setContentScripts not implemented.');
},
onResourceDownloaded: async (resourceId: string) => {
onResourceChanged: async (resourceId: string) => {
const rendered = await renderAndPostprocessHtml(`<img src=":/${resourceId}"/>`);
const renderedImage = rendered.dom.querySelector('img');
@ -285,6 +285,7 @@ const createEditor = async (
}
const resourceSrc = renderedImage?.src;
// TODO: Handle the more general case where the resource changed externally
onResourceDownloaded(view, resourceId, resourceSrc);
},
remove: () => {

View File

@ -48,10 +48,7 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
block.end,
].join(''));
const submitButton = document.createElement('button');
submitButton.appendChild(createTextNode(doneLabel));
submitButton.classList.add('submit');
submitButton.onclick = () => {
const onClose = () => {
if (dialog.close) {
dialog.close();
} 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);
@ -72,7 +74,9 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
focus('createEditorDialog/legacy', editor);
}
return {};
return {
dismiss: onClose,
};
};
export default createEditorDialog;

View File

@ -2,7 +2,7 @@ import { htmlentities } from '@joplin/utils/html';
import { RenderResult } from '../../../../renderer/types';
import createTestEditor from '../../testing/createTestEditor';
import joplinEditorApiPlugin, { getEditorApi, setEditorApi } from '../joplinEditorApiPlugin';
import joplinEditablePlugin from './joplinEditablePlugin';
import joplinEditablePlugin, { editSourceBlockAt, hideSourceBlockEditor } from './joplinEditablePlugin';
import { Second } from '@joplin/utils/time';
const createEditor = (html: string) => {
@ -19,7 +19,7 @@ const findEditButton = (ancestor: Element): HTMLButtonElement => {
const findEditorDialog = () => {
const dialog = document.querySelector('dialog.editor-dialog');
if (!dialog) {
throw new Error('Could not find an open editor dialog.');
return null;
}
const editor = dialog.querySelector('textarea');
@ -117,4 +117,15 @@ describe('joplinEditablePlugin', () => {
hashLinks[1].click();
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();
});
});

View File

@ -1,4 +1,4 @@
import { Plugin } from 'prosemirror-state';
import { Command, EditorState, Plugin } from 'prosemirror-state';
import { Node, NodeSpec, TagParseRule } from 'prosemirror-model';
import { EditorView, NodeView } from 'prosemirror-view';
import sanitizeHtml from '../../utils/sanitizeHtml';
@ -13,6 +13,116 @@ import makeLinksClickableInElement from '../../utils/makeLinksClickableInElement
// writing similar ProseMirror plugins:
// 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 {
contentHtml: string;
source: string;
@ -117,7 +227,6 @@ export const nodeSpecs = {
type GetPosition = ()=> number;
class EditableSourceBlockView implements NodeView {
private editDialogVisible_ = false;
public readonly dom: HTMLElement;
public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) {
if ((node.attrs.contentHtml ?? undefined) === undefined) {
@ -135,58 +244,7 @@ class EditableSourceBlockView implements NodeView {
}
private showEditDialog_() {
if (this.editDialogVisible_) {
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;
},
});
editSourceBlockAt(this.getPosition())(this.view.state, this.view.dispatch, this.view);
}
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: {
nodeViews: {
joplinEditableInline: (node, view, getPos) => new EditableSourceBlockView(node, true, 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;

View File

@ -5,7 +5,7 @@ import { baseKeymap, chainCommands, exitCode, liftEmptyBlock, newlineInCode } fr
import { liftListItem, sinkListItem, splitListItem } from 'prosemirror-schema-list';
import commands from '../commands';
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 canReplaceSelectionWith from '../utils/canReplaceSelectionWith';
@ -60,6 +60,28 @@ const isInEmptyParagraph = (state: EditorState) => {
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) => {
// Avoid adding hard breaks at the beginning of list items
if (isInEmptyListItem(state)) return false;
@ -88,12 +110,12 @@ const keymapExtension = [
'Mod-[': liftListItem(itemType),
'Mod-]': sinkListItem(itemType),
})),
replaceDoubleHardBreaksOnEnter,
keymap({
'Enter': chainCommands(
newlineInCode,
exitCode,
liftEmptyBlock,
convertDoubleHardBreakToNewParagraph,
insertHardBreak,
),
}),

View File

@ -167,6 +167,8 @@ const nodes = addDefaultToplevelAttributes({
title: { default: '', validate: 'string' },
fromMd: { default: false, validate: 'boolean' },
resourceId: { default: null as string|null, validate: 'string|null' },
width: { default: '', validate: 'string' },
height: { default: '', validate: 'string' },
},
parseDOM: [
{
@ -174,6 +176,8 @@ const nodes = addDefaultToplevelAttributes({
getAttrs: node => ({
src: node.getAttribute('src'),
alt: node.getAttribute('alt'),
width: node.getAttribute('width') ?? '',
height: node.getAttribute('height') ?? '',
title: node.getAttribute('title'),
fromMd: node.hasAttribute('data-from-md'),
resourceId: node.getAttribute('data-resource-id') || null,
@ -181,7 +185,7 @@ const nodes = addDefaultToplevelAttributes({
},
],
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 };
if (fromMd) {
@ -190,6 +194,12 @@ const nodes = addDefaultToplevelAttributes({
if (resourceId) {
outputAttrs['data-resource-id'] = resourceId;
}
if (width) {
outputAttrs.width = width;
}
if (height) {
outputAttrs.height = height;
}
return [
'img',

View File

@ -18,6 +18,7 @@
"@joplin/utils": "~3.4",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.14",
"@types/node": "18.19.111",
"@types/react": "18.3.23",
"@types/react-redux": "7.1.33",
"@types/styled-components": "5.1.32",

View File

@ -126,8 +126,9 @@ export interface EditorControl {
setContentScripts(plugins: ContentScriptData[]): Promise<void>;
// Called when a resource associated with the current note finishes downloading.
onResourceDownloaded(id: string): void;
// Called when a resource associated with the current note finishes downloading
// or has been updated in an external editor.
onResourceChanged(id: string): void;
remove(): void;
focus(): void;
@ -147,7 +148,7 @@ export enum EditorKeymap {
export interface EditorTheme extends Theme {
themeId: number;
fontFamily: string;
fontSize?: number;
fontSize: number;
fontSizeUnits?: string;
isDesktop?: boolean;
monospaceFont?: string;

View File

@ -46,7 +46,7 @@
},
"devDependencies": {
"@types/jest": "29.5.14",
"@types/node": "18.19.103",
"@types/node": "18.19.111",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"coveralls": "3.1.1",

View File

@ -17,7 +17,7 @@
},
"devDependencies": {
"@types/jest": "29.5.14",
"@types/node": "18.19.103",
"@types/node": "18.19.111",
"jest": "29.7.0",
"typescript": "5.8.2"
},

View File

@ -4,7 +4,7 @@
"private": true,
"scripts": {
"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": {
"typedoc": "0.17.8",

View File

@ -51,10 +51,10 @@ import handleSyncStartupOperation from './services/synchronizer/utils/handleSync
import SyncTargetJoplinCloud from './SyncTargetJoplinCloud';
import { setAutoFreeze } from 'immer';
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 { setRSA } from './services/e2ee/ppk';
import RSA from './services/e2ee/RSA.node';
import { setRSA } from './services/e2ee/ppk/ppk';
import RSA from './services/e2ee/ppk/RSA.node';
import Resource from './models/Resource';
import { ProfileConfig } from './services/profileConfig/types';
import initProfile from './services/profileConfig/initProfile';
@ -90,8 +90,7 @@ export default class BaseApplication {
private eventEmitter_: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private scheduleAutoAddResourcesIID_: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private database_: any = null;
protected database_: JoplinDatabase = null;
private profileConfig_: ProfileConfig = null;
protected showStackTraces_ = false;
@ -781,6 +780,7 @@ export default class BaseApplication {
options.keychainEnabled ? [KeychainServiceDriverElectron, KeychainServiceDriverNode] : [],
);
await migrateMasterPassword();
await migratePpk();
await handleSyncStartupOperation();
appLogger.info(`Client ID: ${Setting.value('clientId')}`);
@ -891,6 +891,10 @@ export default class BaseApplication {
if (!currentFolder) currentFolder = await Folder.defaultFolder();
Setting.setValue('activeFolderId', currentFolder ? currentFolder.id : '');
if (currentFolder && !this.hasGui()) {
this.currentFolder_ = currentFolder;
}
await setupAutoDeletion();
await MigrationService.instance().run();

View File

@ -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_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.'),
is_shared: sp('Whether the note is published.'),
},
folders: {},
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].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].share_id = sp('The ID of the Joplin Server/Cloud share containing the %s. Empty if not shared.', singular);
}
}

View File

@ -24,7 +24,7 @@ import { FileApi, getSupportsDeltaWithItems, PaginatedList, RemoteItem } from '.
import JoplinDatabase from './JoplinDatabase';
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 { generateKeyPair } from './services/e2ee/ppk';
import { generateKeyPair } from './services/e2ee/ppk/ppk';
import syncDebugLog from './services/synchronizer/syncDebugLog';
import handleConflictAction from './services/synchronizer/utils/handleConflictAction';
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.
// 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
public async start(options: any = null) {
public async start(options: any = null): Promise<any> {
if (!options) options = {};
if (this.state() !== 'idle') {
@ -476,6 +476,7 @@ export default class Synchronizer {
let errorToThrow = null;
let syncLock = null;
let hasCaughtError = false;
try {
await this.api().initialize();
@ -611,12 +612,25 @@ export default class Synchronizer {
// 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
// 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,
// 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
// (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);
let action: SyncAction = null;
@ -1121,6 +1135,8 @@ export default class Synchronizer {
}
} // DELTA STEP
} catch (error) {
hasCaughtError = true;
if (error.code === ErrorCode.MustUpgradeApp) {
this.dispatch({
type: 'MUST_UPGRADE_APP',
@ -1163,9 +1179,12 @@ export default class Synchronizer {
this.syncTargetIsLocked_ = false;
let cancelledBeforeClearedState = false;
if (this.cancelling()) {
logger.info('Synchronisation was cancelled.');
this.cancelling_ = false;
cancelledBeforeClearedState = true;
}
this.progressReport_.completedTime = time.unixMs();
@ -1174,8 +1193,10 @@ export default class Synchronizer {
await this.logSyncSummary(this.progressReport_);
const hasErrors = Synchronizer.reportHasErrors(this.progressReport_);
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
@ -1190,6 +1211,19 @@ export default class Synchronizer {
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;
}
}

View File

@ -44,7 +44,7 @@ export const runtime = (): CommandRuntime => {
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) {
logger.info(`Editor is already visible: ${editorViewId}`);
@ -68,7 +68,7 @@ export const runtime = (): CommandRuntime => {
};
Setting.setValue('plugins.shownEditorViewIds', getUpdatedShownViewIds());
controller.setOpened(show);
await controller.setOpen(show);
},
};
};

View File

@ -23,7 +23,7 @@ const useDeleteHistoryClick = ({
if (response === 0) {
setDeleting(true);
try {
await Revision.deleteHistoryForNote(noteId);
await Revision.deleteHistoryForNote(noteId, { sourceDescription: 'useDeleteHistoryClick' });
await shim.showMessageBox(_('Note history has been deleted.'), { type: MessageBoxType.Info });
} finally {
setDeleting(false);

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