You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-27 20:29:45 +02:00
Compare commits
120 Commits
mobile_plu
...
android-v3
Author | SHA1 | Date | |
---|---|---|---|
|
d7d6fd5ccd | ||
|
23254e6ffd | ||
|
eb8bfd5aec | ||
|
cb5ffd968d | ||
|
7b2b3a4f80 | ||
|
cbfe109c41 | ||
|
c8b01d11d6 | ||
|
b042395fd1 | ||
|
ba5ad18093 | ||
|
ff15232a10 | ||
|
5a6e72197a | ||
|
de555b6871 | ||
|
9a2548a5e3 | ||
|
107996289f | ||
|
c3c0101555 | ||
|
64f3dae8cc | ||
|
a39b51cc97 | ||
|
10bb8ef1a9 | ||
|
60ba22b233 | ||
|
1bfd997be2 | ||
|
81e4a7fb74 | ||
|
360568d325 | ||
|
1aa0f11670 | ||
|
0430ccb3e7 | ||
|
c0d6c1eb0b | ||
|
215f09d73c | ||
|
1f192696de | ||
|
ab86b95fad | ||
|
0f07c0f53a | ||
|
a6d04c4781 | ||
|
bc27f47881 | ||
|
d1d75449f5 | ||
|
bbea5388ed | ||
|
99e773855e | ||
|
55b73347e5 | ||
|
7e8dee4906 | ||
|
69fb1ab104 | ||
|
67ae0ea2d1 | ||
|
cdb61b922b | ||
|
da80443796 | ||
|
1924dd31d2 | ||
|
b831d8c068 | ||
|
4ad1b49769 | ||
|
0d6c1067e3 | ||
|
0bdc38a6be | ||
|
5c35569b5b | ||
|
5f02af9724 | ||
|
975f16d21c | ||
|
06359834d6 | ||
|
0cc0fec8c3 | ||
|
68ab5dcda5 | ||
|
65544123e6 | ||
|
cfbded00e2 | ||
|
a898e17b4c | ||
|
d12e2d9a81 | ||
|
7025321d76 | ||
|
6c890121b9 | ||
|
9c4be00745 | ||
|
7f51712311 | ||
|
502c929c88 | ||
|
1abf9e9602 | ||
|
8bdb6c5d72 | ||
|
9cbd1b855c | ||
|
ae8658554f | ||
|
bc385d59e9 | ||
|
00ccd994e3 | ||
|
9251299289 | ||
|
fe67a44285 | ||
|
50a1b184fd | ||
|
3caa718132 | ||
|
d0e16c0878 | ||
|
4fcb250c27 | ||
|
86e59ad621 | ||
|
12baa9827d | ||
|
95c50ada7c | ||
|
55a57f7baf | ||
|
69b24b4437 | ||
|
5143fae0f6 | ||
|
01a62acfdf | ||
|
c663742689 | ||
|
0c405951ed | ||
|
4b411e600c | ||
|
bf58a52394 | ||
|
36d3736bff | ||
|
4df0b9f851 | ||
|
914b5e230d | ||
|
9278fd7910 | ||
|
2180ad1d9b | ||
|
d301cdf992 | ||
|
200d3c84e0 | ||
|
6cadaa2137 | ||
|
8221081514 | ||
|
dd06b1e680 | ||
|
70e0ae0c2c | ||
|
7aeec923e3 | ||
|
70d64225c8 | ||
|
ad0ecc2320 | ||
|
8a28edcda8 | ||
|
c8640aa7f8 | ||
|
ddf75d6c52 | ||
|
0a42317e07 | ||
|
51ce1b06fe | ||
|
44c735afac | ||
|
c6154cfb4e | ||
|
d2aad1d6c7 | ||
|
3e81cc8585 | ||
|
abc5c062c3 | ||
|
316ef9d960 | ||
|
b870f8344c | ||
|
6f6683d15d | ||
|
da59aef95b | ||
|
c55979cd03 | ||
|
07f4217f17 | ||
|
8a7071179d | ||
|
2c9a12307e | ||
|
dd3864fa47 | ||
|
43c1c5849b | ||
|
5e08ff0621 | ||
|
45838c0223 | ||
|
17e463b6bc |
@@ -158,6 +158,7 @@ packages/app-desktop/commands/exportFolders.js
|
||||
packages/app-desktop/commands/exportNotes.js
|
||||
packages/app-desktop/commands/focusElement.js
|
||||
packages/app-desktop/commands/index.js
|
||||
packages/app-desktop/commands/newAppInstance.js
|
||||
packages/app-desktop/commands/openNoteInNewWindow.js
|
||||
packages/app-desktop/commands/openProfileDirectory.js
|
||||
packages/app-desktop/commands/replaceMisspelling.js
|
||||
@@ -272,6 +273,7 @@ packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
|
||||
packages/app-desktop/gui/NoteEditor/WarningBanner/WarningBanner.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteBody.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/index.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
|
||||
@@ -436,6 +438,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
|
||||
@@ -456,7 +459,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showEditorPlugin.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.js
|
||||
@@ -465,7 +467,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditorPlugin.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleLayoutMoveMode.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleMenuBar.js
|
||||
@@ -686,7 +687,14 @@ packages/app-mobile/components/SideMenuContentNote.js
|
||||
packages/app-mobile/components/TextInput.js
|
||||
packages/app-mobile/components/ToggleSpaceButton.js
|
||||
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.test.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/FocusControl.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/FocusControlProvider.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/MainAppContent.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/ModalWrapper.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/types.js
|
||||
packages/app-mobile/components/app-nav.js
|
||||
packages/app-mobile/components/base-screen.js
|
||||
packages/app-mobile/components/biometrics/BiometricPopup.js
|
||||
@@ -789,12 +797,16 @@ packages/app-mobile/components/screens/ShareManager/index.test.js
|
||||
packages/app-mobile/components/screens/ShareManager/index.js
|
||||
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
|
||||
packages/app-mobile/components/screens/dropbox-login.js
|
||||
packages/app-mobile/components/screens/encryption-config.test.js
|
||||
packages/app-mobile/components/screens/encryption-config.js
|
||||
packages/app-mobile/components/screens/status.js
|
||||
packages/app-mobile/components/screens/tags.js
|
||||
packages/app-mobile/components/side-menu-content.js
|
||||
packages/app-mobile/components/testing/TestProviderStack.js
|
||||
packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js
|
||||
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
|
||||
packages/app-mobile/components/voiceTyping/RecordingControls.js
|
||||
packages/app-mobile/components/voiceTyping/SpeechToTextBanner.js
|
||||
packages/app-mobile/components/voiceTyping/types.js
|
||||
packages/app-mobile/gulpfile.js
|
||||
packages/app-mobile/index.web.js
|
||||
packages/app-mobile/root.js
|
||||
@@ -808,12 +820,11 @@ 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/splitWhisperText.test.js
|
||||
packages/app-mobile/services/voiceTyping/utils/splitWhisperText.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
|
||||
packages/app-mobile/tools/buildInjectedJs/BundledFile.js
|
||||
@@ -954,6 +965,7 @@ packages/editor/CodeMirror/utils/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/utils/overwriteModeExtension.test.js
|
||||
packages/editor/CodeMirror/utils/overwriteModeExtension.js
|
||||
packages/editor/CodeMirror/utils/searchExtension.js
|
||||
packages/editor/CodeMirror/utils/selectedNoteIdExtension.js
|
||||
packages/editor/CodeMirror/utils/setupVim.js
|
||||
packages/editor/SelectionFormatting.js
|
||||
packages/editor/events.js
|
||||
@@ -1021,7 +1033,11 @@ packages/lib/commands/openMasterPasswordDialog.js
|
||||
packages/lib/commands/permanentlyDeleteNote.js
|
||||
packages/lib/commands/renderMarkup.test.js
|
||||
packages/lib/commands/renderMarkup.js
|
||||
packages/lib/commands/showEditorPlugin.js
|
||||
packages/lib/commands/synchronize.js
|
||||
packages/lib/commands/toggleAllFolders.test.js
|
||||
packages/lib/commands/toggleAllFolders.js
|
||||
packages/lib/commands/toggleEditorPlugin.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
@@ -1115,6 +1131,9 @@ packages/lib/models/settings/builtInMetadata.js
|
||||
packages/lib/models/settings/settingValidations.test.js
|
||||
packages/lib/models/settings/settingValidations.js
|
||||
packages/lib/models/settings/types.js
|
||||
packages/lib/models/utils/areAllFoldersCollapsed.test.js
|
||||
packages/lib/models/utils/areAllFoldersCollapsed.js
|
||||
packages/lib/models/utils/getCanBeCollapsedFolderIds.js
|
||||
packages/lib/models/utils/getCollator.js
|
||||
packages/lib/models/utils/getConflictFolderId.js
|
||||
packages/lib/models/utils/isItemId.js
|
||||
@@ -1257,6 +1276,7 @@ packages/lib/services/ocr/utils/filterOcrText.js
|
||||
packages/lib/services/ocr/utils/types.js
|
||||
packages/lib/services/plugins/BasePlatformImplementation.js
|
||||
packages/lib/services/plugins/BasePluginRunner.js
|
||||
packages/lib/services/plugins/EditorPluginHandler.js
|
||||
packages/lib/services/plugins/MenuController.js
|
||||
packages/lib/services/plugins/MenuItemController.js
|
||||
packages/lib/services/plugins/Plugin.js
|
||||
@@ -1303,6 +1323,7 @@ packages/lib/services/plugins/utils/getPluginIssueReportUrl.js
|
||||
packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingKeyPrefix.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingValue.js
|
||||
packages/lib/services/plugins/utils/getShownPluginEditorView.js
|
||||
packages/lib/services/plugins/utils/isCompatible/getDefaultPlatforms.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.test.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.js
|
||||
|
@@ -57,6 +57,8 @@ module.exports = {
|
||||
'tinymce': 'readonly',
|
||||
|
||||
'JSX': 'readonly',
|
||||
|
||||
'NodeJS': 'readonly',
|
||||
},
|
||||
'parserOptions': {
|
||||
'ecmaVersion': 2018,
|
||||
@@ -309,7 +311,7 @@ module.exports = {
|
||||
selector: 'interface',
|
||||
format: null,
|
||||
'filter': {
|
||||
'regex': '^(RSA|RSAKeyPair)$',
|
||||
'regex': '^(RSA|RSAKeyPair|iOS.*)$',
|
||||
'match': true,
|
||||
},
|
||||
},
|
||||
|
4
.github/workflows/github-actions-main.yml
vendored
4
.github/workflows/github-actions-main.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
matrix:
|
||||
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
|
||||
# https://github.com/actions/runner-images/issues/6709
|
||||
os: [macos-13, ubuntu-20.04, windows-2019]
|
||||
os: [macos-13, ubuntu-22.04, windows-2019]
|
||||
steps:
|
||||
|
||||
# Trying to fix random networking issues on Windows
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
matrix:
|
||||
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
|
||||
# https://github.com/actions/runner-images/issues/6709
|
||||
os: [ubuntu-20.04]
|
||||
os: [ubuntu-22.04]
|
||||
steps:
|
||||
|
||||
- name: Install Docker Engine
|
||||
|
31
.gitignore
vendored
31
.gitignore
vendored
@@ -133,6 +133,7 @@ packages/app-desktop/commands/exportFolders.js
|
||||
packages/app-desktop/commands/exportNotes.js
|
||||
packages/app-desktop/commands/focusElement.js
|
||||
packages/app-desktop/commands/index.js
|
||||
packages/app-desktop/commands/newAppInstance.js
|
||||
packages/app-desktop/commands/openNoteInNewWindow.js
|
||||
packages/app-desktop/commands/openProfileDirectory.js
|
||||
packages/app-desktop/commands/replaceMisspelling.js
|
||||
@@ -247,6 +248,7 @@ packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
|
||||
packages/app-desktop/gui/NoteEditor/WarningBanner/WarningBanner.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteBody.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/index.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
|
||||
@@ -411,6 +413,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/leaveSharedFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
|
||||
@@ -431,7 +434,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showEditorPlugin.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.js
|
||||
@@ -440,7 +442,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditorPlugin.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleLayoutMoveMode.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleMenuBar.js
|
||||
@@ -661,7 +662,14 @@ packages/app-mobile/components/SideMenuContentNote.js
|
||||
packages/app-mobile/components/TextInput.js
|
||||
packages/app-mobile/components/ToggleSpaceButton.js
|
||||
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.test.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/FocusControl.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/FocusControlProvider.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/MainAppContent.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/ModalWrapper.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/types.js
|
||||
packages/app-mobile/components/app-nav.js
|
||||
packages/app-mobile/components/base-screen.js
|
||||
packages/app-mobile/components/biometrics/BiometricPopup.js
|
||||
@@ -764,12 +772,16 @@ packages/app-mobile/components/screens/ShareManager/index.test.js
|
||||
packages/app-mobile/components/screens/ShareManager/index.js
|
||||
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
|
||||
packages/app-mobile/components/screens/dropbox-login.js
|
||||
packages/app-mobile/components/screens/encryption-config.test.js
|
||||
packages/app-mobile/components/screens/encryption-config.js
|
||||
packages/app-mobile/components/screens/status.js
|
||||
packages/app-mobile/components/screens/tags.js
|
||||
packages/app-mobile/components/side-menu-content.js
|
||||
packages/app-mobile/components/testing/TestProviderStack.js
|
||||
packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js
|
||||
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
|
||||
packages/app-mobile/components/voiceTyping/RecordingControls.js
|
||||
packages/app-mobile/components/voiceTyping/SpeechToTextBanner.js
|
||||
packages/app-mobile/components/voiceTyping/types.js
|
||||
packages/app-mobile/gulpfile.js
|
||||
packages/app-mobile/index.web.js
|
||||
packages/app-mobile/root.js
|
||||
@@ -783,12 +795,11 @@ 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/splitWhisperText.test.js
|
||||
packages/app-mobile/services/voiceTyping/utils/splitWhisperText.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
|
||||
packages/app-mobile/tools/buildInjectedJs/BundledFile.js
|
||||
@@ -929,6 +940,7 @@ packages/editor/CodeMirror/utils/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/utils/overwriteModeExtension.test.js
|
||||
packages/editor/CodeMirror/utils/overwriteModeExtension.js
|
||||
packages/editor/CodeMirror/utils/searchExtension.js
|
||||
packages/editor/CodeMirror/utils/selectedNoteIdExtension.js
|
||||
packages/editor/CodeMirror/utils/setupVim.js
|
||||
packages/editor/SelectionFormatting.js
|
||||
packages/editor/events.js
|
||||
@@ -996,7 +1008,11 @@ packages/lib/commands/openMasterPasswordDialog.js
|
||||
packages/lib/commands/permanentlyDeleteNote.js
|
||||
packages/lib/commands/renderMarkup.test.js
|
||||
packages/lib/commands/renderMarkup.js
|
||||
packages/lib/commands/showEditorPlugin.js
|
||||
packages/lib/commands/synchronize.js
|
||||
packages/lib/commands/toggleAllFolders.test.js
|
||||
packages/lib/commands/toggleAllFolders.js
|
||||
packages/lib/commands/toggleEditorPlugin.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
@@ -1090,6 +1106,9 @@ packages/lib/models/settings/builtInMetadata.js
|
||||
packages/lib/models/settings/settingValidations.test.js
|
||||
packages/lib/models/settings/settingValidations.js
|
||||
packages/lib/models/settings/types.js
|
||||
packages/lib/models/utils/areAllFoldersCollapsed.test.js
|
||||
packages/lib/models/utils/areAllFoldersCollapsed.js
|
||||
packages/lib/models/utils/getCanBeCollapsedFolderIds.js
|
||||
packages/lib/models/utils/getCollator.js
|
||||
packages/lib/models/utils/getConflictFolderId.js
|
||||
packages/lib/models/utils/isItemId.js
|
||||
@@ -1232,6 +1251,7 @@ packages/lib/services/ocr/utils/filterOcrText.js
|
||||
packages/lib/services/ocr/utils/types.js
|
||||
packages/lib/services/plugins/BasePlatformImplementation.js
|
||||
packages/lib/services/plugins/BasePluginRunner.js
|
||||
packages/lib/services/plugins/EditorPluginHandler.js
|
||||
packages/lib/services/plugins/MenuController.js
|
||||
packages/lib/services/plugins/MenuItemController.js
|
||||
packages/lib/services/plugins/Plugin.js
|
||||
@@ -1278,6 +1298,7 @@ packages/lib/services/plugins/utils/getPluginIssueReportUrl.js
|
||||
packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingKeyPrefix.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingValue.js
|
||||
packages/lib/services/plugins/utils/getShownPluginEditorView.js
|
||||
packages/lib/services/plugins/utils/isCompatible/getDefaultPlatforms.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.test.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.js
|
||||
|
50
.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch
Normal file
50
.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch
Normal file
@@ -0,0 +1,50 @@
|
||||
# This is a (hopefully temporary) fix for an accessibility issue in the FAB.Group
|
||||
# component. See https://github.com/callstack/react-native-paper/pull/4498 for details.
|
||||
diff --git a/lib/commonjs/components/FAB/FABGroup.js b/lib/commonjs/components/FAB/FABGroup.js
|
||||
index 26933dd7ac6862c0dd95e52b8cd91c8bbd0b6efc..417c91a0257849eb597afb5e339e13b6d1d54486 100644
|
||||
--- a/lib/commonjs/components/FAB/FABGroup.js
|
||||
+++ b/lib/commonjs/components/FAB/FABGroup.js
|
||||
@@ -209,8 +209,9 @@ const FABGroup = _ref => {
|
||||
}],
|
||||
pointerEvents: open ? 'box-none' : 'none',
|
||||
accessibilityRole: "button",
|
||||
- importantForAccessibility: "yes",
|
||||
- accessible: true,
|
||||
+ importantForAccessibility: open ? 'yes' : 'no-hide-descendants',
|
||||
+ accessibilityElementsHidden: !open,
|
||||
+ accessible: open,
|
||||
accessibilityLabel: accessibilityLabel
|
||||
}, it.label && /*#__PURE__*/React.createElement(_reactNative.View, null, /*#__PURE__*/React.createElement(_Card.default, {
|
||||
mode: isV3 ? 'contained' : 'elevated',
|
||||
diff --git a/lib/module/components/FAB/FABGroup.js b/lib/module/components/FAB/FABGroup.js
|
||||
index ca5c02679539b17b048d4c82f570791dd8b57545..a06902b744b3bfb06b0644930eda0ba2ce2967ca 100644
|
||||
--- a/lib/module/components/FAB/FABGroup.js
|
||||
+++ b/lib/module/components/FAB/FABGroup.js
|
||||
@@ -200,8 +200,9 @@ const FABGroup = _ref => {
|
||||
}],
|
||||
pointerEvents: open ? 'box-none' : 'none',
|
||||
accessibilityRole: "button",
|
||||
- importantForAccessibility: "yes",
|
||||
- accessible: true,
|
||||
+ importantForAccessibility: open ? 'yes' : 'no-hide-descendants',
|
||||
+ accessibilityElementsHidden: !open,
|
||||
+ accessible: open,
|
||||
accessibilityLabel: accessibilityLabel
|
||||
}, it.label && /*#__PURE__*/React.createElement(View, null, /*#__PURE__*/React.createElement(Card, {
|
||||
mode: isV3 ? 'contained' : 'elevated',
|
||||
diff --git a/src/components/FAB/FABGroup.tsx b/src/components/FAB/FABGroup.tsx
|
||||
index af1e85c4cbabfdd05499f9befb9f851be5911835..d010393975b0b31852efba1b7ce9cb09da4feaec 100644
|
||||
--- a/src/components/FAB/FABGroup.tsx
|
||||
+++ b/src/components/FAB/FABGroup.tsx
|
||||
@@ -383,8 +383,9 @@ const FABGroup = ({
|
||||
]}
|
||||
pointerEvents={open ? 'box-none' : 'none'}
|
||||
accessibilityRole="button"
|
||||
- importantForAccessibility="yes"
|
||||
- accessible={true}
|
||||
+ importantForAccessibility={open ? 'yes' : 'no-hide-descendants'}
|
||||
+ accessibilityElementsHidden={!open}
|
||||
+ accessible={open}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
{it.label && (
|
@@ -0,0 +1,55 @@
|
||||
# This patch improves the note actions menu (the kebab menu)'s accessibility
|
||||
# by labelling its dismiss button.
|
||||
diff --git a/build/rnpm.js b/build/rnpm.js
|
||||
index 1111c2de99b3d4c5651ca4eee3ba59c0ce8e13e1..d410ee12b38d02c399b0a40973217da0082d73c0 100644
|
||||
--- a/build/rnpm.js
|
||||
+++ b/build/rnpm.js
|
||||
@@ -1573,7 +1573,9 @@
|
||||
onPress = _this$props.onPress,
|
||||
style = _this$props.style;
|
||||
return /*#__PURE__*/React__default.createElement(reactNative.TouchableWithoutFeedback, {
|
||||
- onPress: onPress
|
||||
+ onPress: onPress,
|
||||
+ accessibilityLabel: _this$props.accessibilityLabel,
|
||||
+ accessibilityRole: 'button',
|
||||
}, /*#__PURE__*/React__default.createElement(reactNative.Animated.View, {
|
||||
style: [styles.fullscreen, {
|
||||
opacity: this.fadeAnim
|
||||
@@ -1588,7 +1590,8 @@
|
||||
}(React.Component);
|
||||
|
||||
Backdrop.propTypes = {
|
||||
- onPress: propTypes.func.isRequired
|
||||
+ onPress: propTypes.func.isRequired,
|
||||
+ accessibilityLabel: propTypes.string,
|
||||
};
|
||||
var styles = reactNative.StyleSheet.create({
|
||||
fullscreen: {
|
||||
@@ -1658,6 +1661,7 @@
|
||||
style: styles$1.placeholder
|
||||
}, /*#__PURE__*/React__default.createElement(Backdrop, {
|
||||
onPress: ctx._onBackdropPress,
|
||||
+ accessibilityLabel: this.props.closeButtonLabel,
|
||||
style: backdropStyles,
|
||||
ref: ctx.onBackdropRef
|
||||
}), ctx._makeOptions());
|
||||
@@ -2090,6 +2094,7 @@
|
||||
}), /*#__PURE__*/React__default.createElement(MenuPlaceholder, {
|
||||
ctx: this,
|
||||
backdropStyles: customStyles.backdrop,
|
||||
+ closeButtonLabel: this.props.closeButtonLabel,
|
||||
ref: this._onPlaceholderRef
|
||||
}))));
|
||||
}
|
||||
diff --git a/src/index.d.ts b/src/index.d.ts
|
||||
index 1db1e643a915e4bfb715e33354678ec1be219f50..007157e366d1935368bdd8eff5e7a0773e183d0f 100644
|
||||
--- a/src/index.d.ts
|
||||
+++ b/src/index.d.ts
|
||||
@@ -18,6 +18,7 @@ declare module "react-native-popup-menu" {
|
||||
menuProviderWrapper?: StyleProp<ViewStyle>;
|
||||
backdrop?: StyleProp<ViewStyle>;
|
||||
};
|
||||
+ closeButtonLabel: string;
|
||||
backHandler?: boolean | Function;
|
||||
skipInstanceCheck?: boolean;
|
||||
children: React.ReactNode;
|
77
Assets/JoplinLetterBlue.svg
Normal file
77
Assets/JoplinLetterBlue.svg
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 682.66669 682.66669"
|
||||
height="682.66669"
|
||||
width="682.66669"
|
||||
xml:space="preserve"
|
||||
id="svg2"
|
||||
version="1.1"
|
||||
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
|
||||
sodipodi:docname="JoplinLetterBlue.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview13"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:pageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.77490232"
|
||||
inkscape:cx="366.49781"
|
||||
inkscape:cy="360.69062"
|
||||
inkscape:window-width="1366"
|
||||
inkscape:window-height="708"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="30"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg2" />
|
||||
<defs
|
||||
id="defs6">
|
||||
<linearGradient
|
||||
id="linearGradient26"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(-4387.91,4387.91,4387.91,4387.91,4753.95,366.05)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0">
|
||||
<stop
|
||||
id="stop22"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#004caf" />
|
||||
<stop
|
||||
id="stop24"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#1f95f8" />
|
||||
</linearGradient>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath829"><path
|
||||
id="path831"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.999997"
|
||||
d="M 3961.59,4435.23 H 2570.18 c -13.15,0 -23.78,-10.64 -23.78,-23.77 v -441.84 c 0,-14.87 12.04,-26.92 26.92,-26.92 h 190.77 c 77.16,0 139.73,-59.35 146.43,-134.77 V 3505 3336.23 1728.75 1717.36 h -0.052 c 0.48,-16.84 -0.1898,-33.4 -1.83,-49.71 -0.18,-2.38 -0.5003,-4.73 -0.7902,-7.09 -1.0998,-9.53 -2.3199,-19.01 -4.17,-28.29 -1.0098,-5.29 -2.4399,-10.44 -3.7098,-15.65 -1.71,-6.93 -3.09,-13.97 -5.22,-20.75 -12.5802,-40.27 -32.4702,-77.62 -59.9802,-110.5 -1.0098,-1.17 -2.2599,-2.25 -3.2598,-3.41 -8.3901,-9.72 -17.2002,-19.19 -26.9502,-28.06 -9.84,-8.95 -20.2599,-17.27 -31.2099,-25 -77.8401,-55.14 -182.61,-79.4 -299.67,-68.2 -149.2599,14.03 -297.3399,81.72 -417.03,190.62 -119.6701,108.89 -194.08,243.62 -209.4799,379.41 -13.8501,121.48 22.5498,228.38 102.42,301.05 0.21,0.1598 0.3997,0.3098 0.5602,0.48 3.09,2.77 6.4901,5.2 9.6701,7.87 57.16,47.89 131.6701,76.91 216.7,84.91 0.96,0.09 1.8801,0.24 2.79,0.3203 8.9499,0.79 18.0699,1.15 27.27,1.49 4.8099,0.1598 9.5601,0.5003 14.4399,0.54 1.62,0.023 3.1602,0.1898 4.7802,0.1898 2.8998,0 5.91,-0.3803 8.8098,-0.42 13.4001,-0.21 26.9001,-0.7601 40.6701,-1.9401 1.74,-0.1402 3.3999,-0.08 5.19,-0.24 1.2699,-0.1297 2.5299,-0.4102 3.8001,-0.54 78,-7.82 155.2299,-31.11 228.5199,-66.3999 1.53,-0.068 3.3,-0.54 5.5099,-1.7601 22.34,-12.3399 26.6201,0.9 27.2801,9.6501 v 382.2399 282.8201 c 0,19.05 -13.2501,35.8999 -31.83,39.99 -394.7601,86.88 -782.08,-3.5501 -1055.38,-252.3401 -238.7499,-217.1799 -354.24,-530.5799 -316.8201,-859.7899 33.39,-293.23 183.9102,-574.94 423.88,-793.33 233.8901,-212.79003 531.69,-345.86006 838.8801,-374.80106 42.33,-3.918 84.8601,-5.93797 126.36,-5.93797 293.3799,0 565.6099,100.59802 766.54,283.37903 190.3401,173.3 304.35,411.27 321.0799,670.16 l 1.55,1697.91 h 0.1703 v 453.97 h 0.06 v 7.92 c 1.72,80.1199 67.05,144.58 147.61,144.58 h 190.77 c 14.8599,0 26.9199,12.05 26.9199,26.9199 v 441.84 c 0,13.13 -10.6299,23.77 -23.7799,23.77" /></clipPath></defs>
|
||||
<g
|
||||
id="g14"
|
||||
transform="matrix(0.13333333,0,0,-0.13333333,0,682.66667)"
|
||||
mask="none"
|
||||
clip-path="url(#clipPath829)">
|
||||
<g
|
||||
clip-path="url(#clipPath20)"
|
||||
id="g16">
|
||||
<path
|
||||
id="path28"
|
||||
style="fill:url(#linearGradient26);fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 3873.89,0 H 1246.11 C 560.754,0 0,560.75 0,1246.11 V 3873.88 C 0,4559.25 560.754,5120 1246.11,5120 H 3873.89 C 4559.25,5120 5120,4559.25 5120,3873.88 V 1246.11 C 5120,560.75 4559.25,0 3873.89,0" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 4.0 KiB |
BIN
Assets/WebsiteAssets/images/ocr/view_ocr_text.png
Normal file
BIN
Assets/WebsiteAssets/images/ocr/view_ocr_text.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 60 KiB |
@@ -80,7 +80,7 @@ async function setupDownloadPage() {
|
||||
|
||||
if (href.indexOf('-Setup') > 0) downloadLinks['windows'] = href;
|
||||
if (href.indexOf('.dmg') > 0) downloadLinks['macOs'] = href;
|
||||
if (href.endsWith('arm64.DMG')) downloadLinks['macOsM1'] = href;
|
||||
if (href.indexOf('arm64.DMG') > 0) downloadLinks['macOsM1'] = href;
|
||||
if (href.indexOf('.AppImage') > 0) downloadLinks['linux'] = href;
|
||||
});
|
||||
|
||||
@@ -98,6 +98,8 @@ async function setupDownloadPage() {
|
||||
} else {
|
||||
const os = await getOs();
|
||||
|
||||
console.info('Found OS: ' + os);
|
||||
|
||||
if (os === 'macOsUndefined') {
|
||||
// If we don't know which macOS version it is, we let the user choose.
|
||||
$('.main-content .intro').html('<p class="macos-m1-info">The macOS release is available for Intel processors or for Apple Silicon (M1) processors. Please select your version:</p>');
|
||||
|
@@ -398,7 +398,7 @@
|
||||
|
||||
<div class="text-center sponsors-org">
|
||||
{{#sponsors.orgs}}
|
||||
<a class="sponsor-org-item" href="{{url}}"><img title="{{title}}" src="{{imageBaseUrl}}/sponsors/{{imageName}}"></a>
|
||||
<a class="sponsor-org-item" href="{{url}}"><img alt="{{alt}}" title="{{title}}" src="{{imageBaseUrl}}/sponsors/{{imageName}}"></a>
|
||||
{{/sponsors.orgs}}
|
||||
</div>
|
||||
|
||||
|
@@ -67,10 +67,23 @@ showHelp() {
|
||||
fi
|
||||
}
|
||||
|
||||
#-----------------------------------------------------
|
||||
# Setup Download Helper: DL
|
||||
#-----------------------------------------------------
|
||||
if [[ `command -v wget2` ]]; then
|
||||
DL='wget2 -qO'
|
||||
elif [[ `command -v wget` ]]; then
|
||||
DL='wget -qO'
|
||||
elif [[ `command -v curl` ]]; then
|
||||
DL='curl -sLo'
|
||||
else
|
||||
print "${COLOR_RED}Error: wget2, wget, and curl not found. Please install one of these tools.${COLOR_RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#-----------------------------------------------------
|
||||
# PARSE ARGUMENTS
|
||||
#-----------------------------------------------------
|
||||
|
||||
optspec=":h-:"
|
||||
while getopts "${optspec}" OPT; do
|
||||
[ "${OPT}" = " " ] && continue
|
||||
@@ -140,9 +153,9 @@ fi
|
||||
|
||||
# Get the latest version to download
|
||||
if [[ "$INCLUDE_PRE_RELEASE" == true ]]; then
|
||||
RELEASE_VERSION=$(wget -qO - "https://api.github.com/repos/laurent22/joplin/releases" | grep -Po '"tag_name": ?"v\K.*?(?=")' | sort -rV | head -1)
|
||||
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases" | grep -Po '"tag_name": ?"v\K.*?(?=")' | sort -rV | head -1)
|
||||
else
|
||||
RELEASE_VERSION=$(wget -qO - "https://api.github.com/repos/laurent22/joplin/releases/latest" | grep -Po '"tag_name": ?"v\K.*?(?=")')
|
||||
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases/latest" | grep -Po '"tag_name": ?"v\K.*?(?=")')
|
||||
fi
|
||||
|
||||
# Check if it's in the latest version
|
||||
@@ -163,8 +176,8 @@ fi
|
||||
#-----------------------------------------------------
|
||||
print 'Downloading Joplin...'
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
wget -O "${TEMP_DIR}/Joplin.AppImage" "https://objects.joplinusercontent.com/v${RELEASE_VERSION}/Joplin-${RELEASE_VERSION}.AppImage?source=LinuxInstallScript&type=$DOWNLOAD_TYPE"
|
||||
wget -O "${TEMP_DIR}/joplin.png" https://joplinapp.org/images/Icon512.png
|
||||
$DL "${TEMP_DIR}/Joplin.AppImage" "https://objects.joplinusercontent.com/v${RELEASE_VERSION}/Joplin-${RELEASE_VERSION}.AppImage?source=LinuxInstallScript&type=$DOWNLOAD_TYPE"
|
||||
$DL "${TEMP_DIR}/joplin.png" https://joplinapp.org/images/Icon512.png
|
||||
|
||||
#-----------------------------------------------------
|
||||
print 'Installing Joplin...'
|
||||
@@ -287,7 +300,7 @@ echo "$RELEASE_VERSION" > "${INSTALL_DIR}/VERSION"
|
||||
|
||||
#-----------------------------------------------------
|
||||
if [[ "$SHOW_CHANGELOG" == true ]]; then
|
||||
NOTES=$(wget -qO - https://api.github.com/repos/laurent22/joplin/releases/latest | grep -Po '"body": "\K.*(?=")')
|
||||
NOTES=$($DL - https://api.github.com/repos/laurent22/joplin/releases/latest | grep -Po '"body": "\K.*(?=")')
|
||||
print "${COLOR_BLUE}Changelog:${COLOR_RESET}\n${NOTES}"
|
||||
fi
|
||||
|
||||
|
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
# Sponsors
|
||||
|
||||
<!-- SPONSORS-ORG -->
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://buyyoutubviews.com"><img title="BYTV" width="256" src="https://joplinapp.org/images/sponsors/BYTV.png"/></a> <a href="https://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a> <a href="https://useviral.com.br/"><img title="Comprar seguidores Instagram" width="256" src="https://joplinapp.org/images/sponsors/Useviral.png"/></a> <a href="https://ca.edubirdie.com/"><img title="Achieve academic success with Edubirdie — your trusted partner for expert writing assistance and resources!" width="256" src="https://joplinapp.org/images/sponsors/Edubirdie.png" alt="EduBirdie"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="web design agency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a> <a href="https://useviral.com.br/"><img title="Comprar seguidores Instagram" width="256" src="https://joplinapp.org/images/sponsors/Useviral.png"/></a> <a href="https://ca.edubirdie.com/"><img title="Achieve academic success with Edubirdie — your trusted partner for expert writing assistance and resources!" width="256" src="https://joplinapp.org/images/sponsors/Edubirdie.png" alt="EduBirdie"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="web design agency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
|
30
SECURITY.md
30
SECURITY.md
@@ -10,6 +10,36 @@ Please [contact support](https://raw.githubusercontent.com/laurent22/joplin/dev/
|
||||
|
||||
For general opinions on what makes an app more or less secure, please use the forum.
|
||||
|
||||
## Areas outside Joplin's Threat Model
|
||||
|
||||
Note: we're mostly linking to Chrome's documentation since our reasoning for these exclusions is the same.
|
||||
|
||||
### Denial of Service (DoS)
|
||||
|
||||
[Reference](https://chromium.googlesource.com/chromium/src.git/+/master/docs/security/faq.md#are-denial-of-service-issues-considered-security-bugs)
|
||||
|
||||
### Physically-local attacks
|
||||
|
||||
[Reference](https://chromium.googlesource.com/chromium/src.git/+/master/docs/security/faq.md#why-arent-physically_local-attacks-in-chromes-threat-model)
|
||||
|
||||
### Compromised/infected machines
|
||||
|
||||
[Reference](https://chromium.googlesource.com/chromium/src.git/+/master/docs/security/faq.md#why-arent-compromised_infected-machines-in-chromes-threat-model)
|
||||
|
||||
### Is opening a file on the local machine a security vulnerability?
|
||||
|
||||
No - users are allowed to link to files on their local computer. This was a feature that was implemented by popular request. There are measures in place to mitigate security risks such as a dialog to confirm whether a file with an unknown file extension should be opened.
|
||||
|
||||
### Is DLL sideloading a security vulnerability?
|
||||
|
||||
No. This is an Electron issue and not one they will fix: https://github.com/electron/electron/issues/28384
|
||||
|
||||
See also [Physically-local attacks](https://chromium.googlesource.com/chromium/src.git/+/master/docs/security/faq.md#why-arent-physically_local-attacks-in-chromes-threat-model)
|
||||
|
||||
### Is local data not being encrypted a security vulnerability?
|
||||
|
||||
No, but you should use disk encryption. See also [Physically-local attacks](https://chromium.googlesource.com/chromium/src.git/+/master/docs/security/faq.md#why-arent-physically_local-attacks-in-chromes-threat-model)
|
||||
|
||||
## Bounty
|
||||
|
||||
We **do not** offer a bounty for discovering vulnerabilities, please do not ask. We can however credit you and link to your website in the changelog and release announcement.
|
||||
|
@@ -33,6 +33,7 @@
|
||||
"/packages/app-desktop/build/",
|
||||
"/packages/app-desktop/utils/checkForUpdatesUtilsTestData.ts",
|
||||
"/packages/app-desktop/vendor/",
|
||||
"/packages/app-mobile/android/vendor/",
|
||||
"/packages/app-mobile/ios/Pods/",
|
||||
"/packages/app-mobile/lib/rnInjectedJs",
|
||||
"/packages/app-mobile/pluginAssets",
|
||||
|
@@ -25,7 +25,8 @@
|
||||
"version": "latest",
|
||||
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"git": "latest",
|
||||
"git": "latest",
|
||||
"giflib": "latest",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
@@ -16,7 +16,7 @@ services:
|
||||
- POSTGRES_DATABASE=joplin
|
||||
- POSTGRES_USER=joplin
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_HOST=localhost
|
||||
- POSTGRES_HOST=db
|
||||
db:
|
||||
image: postgres:16
|
||||
ports:
|
||||
|
1
fastlane/metadata/android/uk/short_description.txt
Normal file
1
fastlane/metadata/android/uk/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Додаток для заміток і завдань із синхронізацією між Linux, macOS, Windows і мобільними пристроями
|
@@ -115,6 +115,8 @@
|
||||
"rn-fetch-blob@0.12.0": "patch:rn-fetch-blob@npm%3A0.12.0#./.yarn/patches/rn-fetch-blob-npm-0.12.0-cf02e3c544.patch",
|
||||
"app-builder-lib@26.0.0-alpha.7": "patch:app-builder-lib@npm%3A26.0.0-alpha.7#./.yarn/patches/app-builder-lib-npm-26.0.0-alpha.7-e1b3dca119.patch",
|
||||
"app-builder-lib@24.13.3": "patch:app-builder-lib@npm%3A24.13.3#./.yarn/patches/app-builder-lib-npm-24.13.3-86a66c0bf3.patch",
|
||||
"react-native-sqlite-storage@6.0.1": "patch:react-native-sqlite-storage@npm%3A6.0.1#./.yarn/patches/react-native-sqlite-storage-npm-6.0.1-8369d747bd.patch"
|
||||
"react-native-sqlite-storage@6.0.1": "patch:react-native-sqlite-storage@npm%3A6.0.1#./.yarn/patches/react-native-sqlite-storage-npm-6.0.1-8369d747bd.patch",
|
||||
"react-native-paper@5.13.1": "patch:react-native-paper@npm%3A5.13.1#./.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch",
|
||||
"react-native-popup-menu@0.16.1": "patch:react-native-popup-menu@npm%3A0.16.1#./.yarn/patches/react-native-popup-menu-npm-0.16.1-28fd66ecb5.patch"
|
||||
}
|
||||
}
|
||||
|
@@ -72,7 +72,7 @@
|
||||
"@joplin/tools": "~3.3",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "18.19.64",
|
||||
"@types/node": "18.19.67",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
|
BIN
packages/app-cli/tests/support/onenote/corrupted_attachment.zip
Normal file
BIN
packages/app-cli/tests/support/onenote/corrupted_attachment.zip
Normal file
Binary file not shown.
Binary file not shown.
@@ -1,11 +1,12 @@
|
||||
import Logger, { LoggerWrapper } from '@joplin/utils/Logger';
|
||||
import Logger, { LoggerWrapper, TargetType } from '@joplin/utils/Logger';
|
||||
import { PluginMessage } from './services/plugins/PluginRunner';
|
||||
import AutoUpdaterService, { defaultUpdateInterval, initialUpdateStartup } from './services/autoUpdater/AutoUpdaterService';
|
||||
import type ShimType from '@joplin/lib/shim';
|
||||
const shim: typeof ShimType = require('@joplin/lib/shim').default;
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
|
||||
import { BrowserWindow, Tray, WebContents, screen } from 'electron';
|
||||
import { FileLocker } from '@joplin/utils/fs';
|
||||
import { IpcMessageHandler, IpcServer, Message, newHttpError, sendMessage, SendMessageOptions, startServer, stopServer } from '@joplin/utils/ipc';
|
||||
import { BrowserWindow, Tray, WebContents, screen, App } from 'electron';
|
||||
import bridge from './bridge';
|
||||
const url = require('url');
|
||||
const path = require('path');
|
||||
@@ -19,6 +20,7 @@ import handleCustomProtocols, { CustomProtocolHandler } from './utils/customProt
|
||||
import { clearTimeout, setTimeout } from 'timers';
|
||||
import { resolve } from 'path';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
|
||||
interface RendererProcessQuitReply {
|
||||
canClose: boolean;
|
||||
@@ -36,8 +38,7 @@ interface SecondaryWindowData {
|
||||
|
||||
export default class ElectronAppWrapper {
|
||||
private logger_: Logger = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private electronApp_: any;
|
||||
private electronApp_: App;
|
||||
private env_: string;
|
||||
private isDebugMode_: boolean;
|
||||
private profilePath_: string;
|
||||
@@ -58,13 +59,28 @@ export default class ElectronAppWrapper {
|
||||
private customProtocolHandler_: CustomProtocolHandler = null;
|
||||
private updatePollInterval_: ReturnType<typeof setTimeout>|null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public constructor(electronApp: any, env: string, profilePath: string|null, isDebugMode: boolean, initialCallbackUrl: string) {
|
||||
private profileLocker_: FileLocker|null = null;
|
||||
private ipcServer_: IpcServer|null = null;
|
||||
private ipcStartPort_ = 2658;
|
||||
|
||||
private ipcLogger_: Logger;
|
||||
|
||||
public constructor(electronApp: App, env: string, profilePath: string|null, isDebugMode: boolean, initialCallbackUrl: string) {
|
||||
this.electronApp_ = electronApp;
|
||||
this.env_ = env;
|
||||
this.isDebugMode_ = isDebugMode;
|
||||
this.profilePath_ = profilePath;
|
||||
this.initialCallbackUrl_ = initialCallbackUrl;
|
||||
|
||||
this.profileLocker_ = new FileLocker(`${this.profilePath_}/lock`);
|
||||
|
||||
// Note: in certain contexts `this.logger_` doesn't seem to be available, especially for IPC
|
||||
// calls, either because it hasn't been set or other issue. So we set one here specifically
|
||||
// for this.
|
||||
this.ipcLogger_ = new Logger();
|
||||
this.ipcLogger_.addTarget(TargetType.File, {
|
||||
path: `${profilePath}/log-cross-app-ipc.txt`,
|
||||
});
|
||||
}
|
||||
|
||||
public electronApp() {
|
||||
@@ -262,15 +278,25 @@ export default class ElectronAppWrapper {
|
||||
// the easiest is to use a timeout. Keep in mind that if you get a white window on Windows it might be due
|
||||
// to this line though.
|
||||
if (debugEarlyBugs) {
|
||||
setTimeout(() => {
|
||||
// Since a recent release of Electron (v34?), calling openDevTools() here does nothing
|
||||
// if a plugin devtool window is already opened. Maybe because they do a check on
|
||||
// `isDevToolsOpened` which indeed returns `true` (but shouldn't since it's for a
|
||||
// different window). However, if you open the dev tools twice from the Help menu it
|
||||
// works. So instead we do that here and call openDevTool() three times.
|
||||
let openDevToolCount = 0;
|
||||
const openDevToolInterval = setInterval(() => {
|
||||
try {
|
||||
this.win_.webContents.openDevTools();
|
||||
openDevToolCount++;
|
||||
if (openDevToolCount >= 3) {
|
||||
clearInterval(openDevToolInterval);
|
||||
}
|
||||
} catch (error) {
|
||||
// This will throw an exception "Object has been destroyed" if the app is closed
|
||||
// in less that the timeout interval. It can be ignored.
|
||||
// This will throw an exception "Object has been destroyed" if the app is closed
|
||||
// in less that the timeout interval. It can be ignored.
|
||||
console.warn('Error opening dev tools', error);
|
||||
}
|
||||
}, 3000);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const addWindowEventHandlers = (webContents: WebContents) => {
|
||||
@@ -400,7 +426,7 @@ export default class ElectronAppWrapper {
|
||||
if (message.target === 'plugin') {
|
||||
const win = this.pluginWindows_[message.pluginId];
|
||||
if (!win) {
|
||||
this.logger().error(`Trying to send IPC message to non-existing plugin window: ${message.pluginId}`);
|
||||
this.ipcLogger_.error(`Trying to send IPC message to non-existing plugin window: ${message.pluginId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -455,12 +481,24 @@ export default class ElectronAppWrapper {
|
||||
});
|
||||
}
|
||||
|
||||
public quit() {
|
||||
private onExit() {
|
||||
this.stopPeriodicUpdateCheck();
|
||||
this.profileLocker_.unlockSync();
|
||||
|
||||
// Probably doesn't matter if the server is not closed cleanly? Thus the lack of `await`
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Needed here because onExit() is not async
|
||||
void stopServer(this.ipcServer_).catch(_error => {
|
||||
// Ignore it since we're stopping, and to prevent unnecessary messages.
|
||||
});
|
||||
}
|
||||
|
||||
public quit() {
|
||||
this.onExit();
|
||||
this.electronApp_.quit();
|
||||
}
|
||||
|
||||
public exit(errorCode = 0) {
|
||||
this.onExit();
|
||||
this.electronApp_.exit(errorCode);
|
||||
}
|
||||
|
||||
@@ -526,20 +564,26 @@ export default class ElectronAppWrapper {
|
||||
this.tray_ = null;
|
||||
}
|
||||
|
||||
public ensureSingleInstance() {
|
||||
if (this.env_ === 'dev') return false;
|
||||
public async sendCrossAppIpcMessage(message: Message, port: number|null = null, options: SendMessageOptions = null) {
|
||||
this.ipcLogger_.info('Sending message:', message);
|
||||
|
||||
const gotTheLock = this.electronApp_.requestSingleInstanceLock();
|
||||
if (port === null) port = this.ipcStartPort_;
|
||||
|
||||
if (!gotTheLock) {
|
||||
// Another instance is already running - exit
|
||||
this.quit();
|
||||
return true;
|
||||
return await sendMessage(port, { ...message, sourcePort: this.ipcServer_.port }, {
|
||||
logger: this.ipcLogger_,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
public async ensureSingleInstance() {
|
||||
// if (this.env_ === 'dev') return false;
|
||||
|
||||
interface OnSecondInstanceMessageData {
|
||||
profilePath: string;
|
||||
argv: string[];
|
||||
}
|
||||
|
||||
// Someone tried to open a second instance - focus our window instead
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
this.electronApp_.on('second-instance', (_e: any, argv: string[]) => {
|
||||
const activateWindow = (argv: string[]) => {
|
||||
const win = this.mainWindow();
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
@@ -552,9 +596,85 @@ export default class ElectronAppWrapper {
|
||||
void this.openCallbackUrl(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const messageHandlers: Record<string, IpcMessageHandler> = {
|
||||
'onSecondInstance': async (message) => {
|
||||
const data = message.data as OnSecondInstanceMessageData;
|
||||
if (data.profilePath === this.profilePath_) activateWindow(data.argv);
|
||||
},
|
||||
|
||||
'restartAltInstance': async (message) => {
|
||||
if (bridge().altInstanceId()) return false;
|
||||
|
||||
// We do this in a timeout after a short interval because we need this call to
|
||||
// return the response immediately, so that the caller can call `quit()`
|
||||
setTimeout(async () => {
|
||||
const maxWait = 10000;
|
||||
const interval = 300;
|
||||
const loopCount = Math.ceil(maxWait / interval);
|
||||
let callingAppGone = false;
|
||||
|
||||
for (let i = 0; i < loopCount; i++) {
|
||||
const response = await this.sendCrossAppIpcMessage({
|
||||
action: 'ping',
|
||||
data: null,
|
||||
}, message.sourcePort, {
|
||||
sendToSpecificPortOnly: true,
|
||||
});
|
||||
|
||||
if (!response.length) {
|
||||
callingAppGone = true;
|
||||
break;
|
||||
}
|
||||
|
||||
await msleep(interval);
|
||||
}
|
||||
|
||||
if (callingAppGone) {
|
||||
this.ipcLogger_.warn('restartAltInstance: App is gone - restarting it');
|
||||
void bridge().launchNewAppInstance(this.env());
|
||||
} else {
|
||||
this.ipcLogger_.warn('restartAltInstance: Could not restart calling app because it was still open');
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
'ping': async (_message) => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
this.ipcServer_ = await startServer(this.ipcStartPort_, async (message) => {
|
||||
if (messageHandlers[message.action]) {
|
||||
this.ipcLogger_.info('Got message:', message);
|
||||
return messageHandlers[message.action](message);
|
||||
}
|
||||
|
||||
throw newHttpError(404);
|
||||
}, {
|
||||
logger: this.ipcLogger_,
|
||||
});
|
||||
|
||||
return false;
|
||||
// First check that no other app is running from that profile folder
|
||||
const gotAppLock = await this.profileLocker_.lock();
|
||||
if (gotAppLock) return false;
|
||||
|
||||
const message: Message = {
|
||||
action: 'onSecondInstance',
|
||||
data: {
|
||||
senderPort: this.ipcServer_.port,
|
||||
profilePath: this.profilePath_,
|
||||
argv: process.argv,
|
||||
},
|
||||
};
|
||||
|
||||
await this.sendCrossAppIpcMessage(message);
|
||||
|
||||
this.quit();
|
||||
return true;
|
||||
}
|
||||
|
||||
public initializeCustomProtocolHandler(logger: LoggerWrapper) {
|
||||
@@ -596,7 +716,7 @@ export default class ElectronAppWrapper {
|
||||
// the "ready" event. So we use the function below to make sure that the app is ready.
|
||||
await this.waitForElectronAppReady();
|
||||
|
||||
const alreadyRunning = this.ensureSingleInstance();
|
||||
const alreadyRunning = await this.ensureSingleInstance();
|
||||
if (alreadyRunning) return;
|
||||
|
||||
this.createWindow();
|
||||
|
@@ -617,10 +617,11 @@ class Application extends BaseApplication {
|
||||
clipperLogger.addTarget(TargetType.Console);
|
||||
|
||||
ClipperServer.instance().initialize(actionApi);
|
||||
ClipperServer.instance().setEnabled(!Setting.value('altInstanceId'));
|
||||
ClipperServer.instance().setLogger(clipperLogger);
|
||||
ClipperServer.instance().setDispatch(this.store().dispatch);
|
||||
|
||||
if (Setting.value('clipperServer.autoStart')) {
|
||||
if (ClipperServer.instance().enabled() && Setting.value('clipperServer.autoStart')) {
|
||||
void ClipperServer.instance().start();
|
||||
}
|
||||
|
||||
|
@@ -15,6 +15,7 @@ import isSafeToOpen from './utils/isSafeToOpen';
|
||||
import { closeSync, openSync, readSync, statSync } from 'fs';
|
||||
import { KB } from '@joplin/utils/bytes';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import { execCommand } from '@joplin/utils';
|
||||
|
||||
interface LastSelectedPath {
|
||||
file: string;
|
||||
@@ -43,16 +44,18 @@ export class Bridge {
|
||||
private appName_: string;
|
||||
private appId_: string;
|
||||
private logFilePath_ = '';
|
||||
private altInstanceId_ = '';
|
||||
|
||||
private extraAllowedExtensions_: string[] = [];
|
||||
private onAllowedExtensionsChangeListener_: OnAllowedExtensionsChange = ()=>{};
|
||||
|
||||
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean) {
|
||||
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
|
||||
this.electronWrapper_ = electronWrapper;
|
||||
this.appId_ = appId;
|
||||
this.appName_ = appName;
|
||||
this.rootProfileDir_ = rootProfileDir;
|
||||
this.autoUploadCrashDumps_ = autoUploadCrashDumps;
|
||||
this.altInstanceId_ = altInstanceId;
|
||||
this.lastSelectedPaths_ = {
|
||||
file: null,
|
||||
directory: null,
|
||||
@@ -118,6 +121,8 @@ export class Bridge {
|
||||
return event;
|
||||
}
|
||||
},
|
||||
|
||||
integrations: [Sentry.electronMinidumpIntegration()],
|
||||
};
|
||||
|
||||
if (this.autoUploadCrashDumps_) options.dsn = 'https://cceec550871b1e8a10fee4c7a28d5cf2@o4506576757522432.ingest.sentry.io/4506594281783296';
|
||||
@@ -216,6 +221,10 @@ export class Bridge {
|
||||
return this.electronApp().electronApp().getLocale();
|
||||
};
|
||||
|
||||
public altInstanceId() {
|
||||
return this.altInstanceId_;
|
||||
}
|
||||
|
||||
// Applies to electron-context-menu@3:
|
||||
//
|
||||
// For now we have to disable spell checking in non-editor text
|
||||
@@ -489,7 +498,38 @@ export class Bridge {
|
||||
}
|
||||
}
|
||||
|
||||
public restart(linuxSafeRestart = true) {
|
||||
public appLaunchCommand(env: string, altInstanceId = '') {
|
||||
const altInstanceArgs = altInstanceId ? ['--alt-instance-id', altInstanceId] : [];
|
||||
|
||||
if (env === 'dev') {
|
||||
// This is convenient to quickly test on dev, but the path needs to be adjusted
|
||||
// depending on how things are setup.
|
||||
|
||||
return {
|
||||
execPath: `${homedir()}/.npm-global/bin/electron`,
|
||||
args: [
|
||||
`${homedir()}/src/joplin/packages/app-desktop`,
|
||||
'--env', 'dev',
|
||||
'--log-level', 'debug',
|
||||
'--open-dev-tools',
|
||||
'--no-welcome',
|
||||
].concat(altInstanceArgs),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
execPath: bridge().electronApp().electronApp().getPath('exe'),
|
||||
args: [].concat(altInstanceArgs),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async launchNewAppInstance(env: string) {
|
||||
const cmd = this.appLaunchCommand(env, 'alt1');
|
||||
|
||||
await execCommand([cmd.execPath].concat(cmd.args), { detached: true });
|
||||
}
|
||||
|
||||
public async restart() {
|
||||
// Note that in this case we are not sending the "appClose" event
|
||||
// to notify services and component that the app is about to close
|
||||
// but for the current use-case it's not really needed.
|
||||
@@ -500,8 +540,34 @@ export class Bridge {
|
||||
execPath: process.env.PORTABLE_EXECUTABLE_FILE,
|
||||
};
|
||||
app.relaunch(options);
|
||||
} else if (shim.isLinux() && linuxSafeRestart) {
|
||||
this.showInfoMessageBox(_('The app is now going to close. Please relaunch it to complete the process.'));
|
||||
} else if (this.altInstanceId_) {
|
||||
// Couldn't get it to work using relaunch() - it would just "close" the app, but it
|
||||
// would still be open in the tray except unusable. Or maybe it reopens it quickly but
|
||||
// in a broken state. It might be due to the way it is launched from the main instance.
|
||||
// So here we ask the main instance to relaunch this app after a short delay.
|
||||
|
||||
const responses = await this.electronApp().sendCrossAppIpcMessage({
|
||||
action: 'restartAltInstance',
|
||||
data: null,
|
||||
});
|
||||
|
||||
// However is the main instance is not running, we're stuck, so the user needs to
|
||||
// manually restart. `relaunch()` doesn't appear to work even when the main instance is
|
||||
// not running.
|
||||
const r = responses.find(r => !!r.response);
|
||||
|
||||
if (!r || !r.response) {
|
||||
this.showInfoMessageBox(_('The app is now going to close. Please relaunch it to complete the process.'));
|
||||
|
||||
// Note: this should work, but doesn't:
|
||||
|
||||
// const cmd = this.appLaunchCommand(this.env(), this.altInstanceId_);
|
||||
|
||||
// app.relaunch({
|
||||
// execPath: cmd.execPath,
|
||||
// args: cmd.args,
|
||||
// });
|
||||
}
|
||||
} else {
|
||||
app.relaunch();
|
||||
}
|
||||
@@ -532,9 +598,9 @@ export class Bridge {
|
||||
|
||||
let bridge_: Bridge = null;
|
||||
|
||||
export function initBridge(wrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean) {
|
||||
export function initBridge(wrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
|
||||
if (bridge_) throw new Error('Bridge already initialized');
|
||||
bridge_ = new Bridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps);
|
||||
bridge_ = new Bridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId);
|
||||
return bridge_;
|
||||
}
|
||||
|
||||
|
@@ -6,6 +6,7 @@ import * as exportDeletionLog from './exportDeletionLog';
|
||||
import * as exportFolders from './exportFolders';
|
||||
import * as exportNotes from './exportNotes';
|
||||
import * as focusElement from './focusElement';
|
||||
import * as newAppInstance from './newAppInstance';
|
||||
import * as openNoteInNewWindow from './openNoteInNewWindow';
|
||||
import * as openProfileDirectory from './openProfileDirectory';
|
||||
import * as replaceMisspelling from './replaceMisspelling';
|
||||
@@ -28,6 +29,7 @@ const index: any[] = [
|
||||
exportFolders,
|
||||
exportNotes,
|
||||
focusElement,
|
||||
newAppInstance,
|
||||
openNoteInNewWindow,
|
||||
openProfileDirectory,
|
||||
replaceMisspelling,
|
||||
|
19
packages/app-desktop/commands/newAppInstance.ts
Normal file
19
packages/app-desktop/commands/newAppInstance.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../services/bridge';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'newAppInstance',
|
||||
label: () => _('New application instance...'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await bridge().launchNewAppInstance(Setting.value('env'));
|
||||
},
|
||||
|
||||
enabledCondition: '!isAltInstance',
|
||||
};
|
||||
};
|
@@ -24,6 +24,7 @@ class ClipperConfigScreenComponent extends React.Component {
|
||||
}
|
||||
|
||||
private enableClipperServer_click() {
|
||||
if (!ClipperServer.instance().enabled()) return;
|
||||
Setting.setValue('clipperServer.autoStart', true);
|
||||
void ClipperServer.instance().start();
|
||||
}
|
||||
@@ -70,6 +71,8 @@ class ClipperConfigScreenComponent extends React.Component {
|
||||
|
||||
const webClipperStatusComps = [];
|
||||
|
||||
const clipperEnabled = ClipperServer.instance().enabled();
|
||||
|
||||
if (this.props.clipperServerAutoStart) {
|
||||
webClipperStatusComps.push(
|
||||
<p key="text_1" style={theme.textStyle}>
|
||||
@@ -95,13 +98,22 @@ class ClipperConfigScreenComponent extends React.Component {
|
||||
</button>,
|
||||
);
|
||||
} else {
|
||||
if (!clipperEnabled) {
|
||||
webClipperStatusComps.push(
|
||||
<p key="text_4" style={theme.textStyle}>
|
||||
{_('The web clipper service cannot be enabled in this instance of Joplin.')}
|
||||
</p>,
|
||||
);
|
||||
} else {
|
||||
webClipperStatusComps.push(
|
||||
<p key="text_4" style={theme.textStyle}>
|
||||
{_('The web clipper service is not enabled.')}
|
||||
</p>,
|
||||
);
|
||||
}
|
||||
|
||||
webClipperStatusComps.push(
|
||||
<p key="text_4" style={theme.textStyle}>
|
||||
{_('The web clipper service is not enabled.')}
|
||||
</p>,
|
||||
);
|
||||
webClipperStatusComps.push(
|
||||
<button key="enable_button" style={buttonStyle} onClick={this.enableClipperServer_click}>
|
||||
<button key="enable_button" style={buttonStyle} onClick={this.enableClipperServer_click} disabled={!clipperEnabled}>
|
||||
{_('Enable Web Clipper Service')}
|
||||
</button>,
|
||||
);
|
||||
|
@@ -63,23 +63,70 @@ const Dialog: React.FC<Props> = props => {
|
||||
</div>;
|
||||
};
|
||||
|
||||
// We keep track of the mouse events to allow the action to be cancellable on the mouseup
|
||||
// If dialogElement is the source of the mouse event it means
|
||||
// that the user clicked in the dimmed background and not in the content of the dialog
|
||||
const useClickedOutsideContent = (dialogElement: HTMLDialogElement|null) => {
|
||||
const mouseDownOutsideContent = useRef(false);
|
||||
mouseDownOutsideContent.current = false;
|
||||
const [clickedOutsideContent, setClickedOutsideContent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogElement) return () => {};
|
||||
|
||||
const mouseDownListener = (event: MouseEvent) => {
|
||||
if (event.target === dialogElement) {
|
||||
mouseDownOutsideContent.current = true;
|
||||
} else {
|
||||
mouseDownOutsideContent.current = false;
|
||||
}
|
||||
};
|
||||
const mouseUpListener = (event: MouseEvent) => {
|
||||
if (!mouseDownOutsideContent.current) return;
|
||||
if (mouseDownOutsideContent.current && event.target === dialogElement) {
|
||||
setClickedOutsideContent(true);
|
||||
mouseDownOutsideContent.current = false;
|
||||
} else {
|
||||
setClickedOutsideContent(false);
|
||||
mouseDownOutsideContent.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
dialogElement.addEventListener('mousedown', mouseDownListener);
|
||||
dialogElement.addEventListener('mouseup', mouseUpListener);
|
||||
|
||||
return () => {
|
||||
dialogElement.removeEventListener('mousedown', mouseDownListener);
|
||||
dialogElement.removeEventListener('mouseup', mouseUpListener);
|
||||
};
|
||||
}, [dialogElement]);
|
||||
|
||||
return [clickedOutsideContent, setClickedOutsideContent] as const;
|
||||
};
|
||||
|
||||
const useDialogElement = (containerDocument: Document, onCancel: undefined|OnCancelListener) => {
|
||||
const [dialogElement, setDialogElement] = useState<HTMLDialogElement|null>(null);
|
||||
|
||||
const onCancelRef = useRef(onCancel);
|
||||
onCancelRef.current = onCancel;
|
||||
|
||||
const [clickedOutsideContent, setClickedOutsideContent] = useClickedOutsideContent(dialogElement);
|
||||
|
||||
useEffect(() => {
|
||||
if (clickedOutsideContent) {
|
||||
const onCancel = onCancelRef.current;
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
setClickedOutsideContent(false);
|
||||
}
|
||||
}
|
||||
}, [clickedOutsideContent, setClickedOutsideContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerDocument) return () => {};
|
||||
|
||||
const dialog = containerDocument.createElement('dialog');
|
||||
dialog.addEventListener('click', event => {
|
||||
const onCancel = onCancelRef.current;
|
||||
const isBackgroundClick = event.target === dialog;
|
||||
if (isBackgroundClick && onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
dialog.classList.add('dialog-modal-layer');
|
||||
dialog.addEventListener('cancel', event => {
|
||||
const canCancel = !!onCancelRef.current;
|
||||
|
@@ -478,6 +478,10 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
});
|
||||
};
|
||||
|
||||
const onDisableSync = () => {
|
||||
Setting.setValue('sync.target', null);
|
||||
};
|
||||
|
||||
const onViewSyncSettingsScreen = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
@@ -575,6 +579,8 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
_('Your Joplin Cloud credentials are invalid, please login.'),
|
||||
_('Login to Joplin Cloud.'),
|
||||
onViewJoplinCloudLoginScreen,
|
||||
_('Disable synchronisation'),
|
||||
onDisableSync,
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -172,6 +172,7 @@ interface Props {
|
||||
pluginMenus: any[];
|
||||
['spellChecker.enabled']: boolean;
|
||||
['spellChecker.languages']: string[];
|
||||
markdownEditorVisible: boolean;
|
||||
plugins: PluginStates;
|
||||
customCss: string;
|
||||
locale: string;
|
||||
@@ -278,6 +279,7 @@ function useMenuStates(menu: any, props: Props) {
|
||||
props['notes.sortOrder.reverse'],
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
props['folders.sortOrder.reverse'],
|
||||
props.markdownEditorVisible,
|
||||
props.tabMovesFocus,
|
||||
props.noteListRendererId,
|
||||
props.showNoteCounts,
|
||||
@@ -479,6 +481,7 @@ function useMenu(props: Props) {
|
||||
menuItemDic.focusElementNoteList,
|
||||
menuItemDic.focusElementNoteTitle,
|
||||
menuItemDic.focusElementNoteBody,
|
||||
menuItemDic.focusElementNoteViewer,
|
||||
menuItemDic.focusElementToolbar,
|
||||
];
|
||||
|
||||
@@ -552,6 +555,7 @@ function useMenu(props: Props) {
|
||||
const newFolderItem = menuItemDic.newFolder;
|
||||
const newSubFolderItem = menuItemDic.newSubFolder;
|
||||
const printItem = menuItemDic.print;
|
||||
const newAppInstance = menuItemDic.newAppInstance;
|
||||
const switchProfileItem = {
|
||||
label: _('Switch profile'),
|
||||
submenu: switchProfileMenuItems,
|
||||
@@ -715,8 +719,11 @@ function useMenu(props: Props) {
|
||||
}, {
|
||||
type: 'separator',
|
||||
},
|
||||
printItem,
|
||||
printItem, {
|
||||
type: 'separator',
|
||||
},
|
||||
switchProfileItem,
|
||||
newAppInstance,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -789,6 +796,7 @@ function useMenu(props: Props) {
|
||||
shim.isMac() ? noItem : menuItemDic.toggleMenuBar,
|
||||
menuItemDic.toggleNoteList,
|
||||
menuItemDic.toggleVisiblePanes,
|
||||
menuItemDic.toggleEditorPlugin,
|
||||
{
|
||||
label: _('Layout button sequence'),
|
||||
submenu: layoutButtonSequenceMenuItems,
|
||||
@@ -999,6 +1007,7 @@ function useMenu(props: Props) {
|
||||
|
||||
rootMenus.go.submenu.push(menuItemDic.gotoAnything);
|
||||
rootMenus.tools.submenu.push(menuItemDic.commandPalette);
|
||||
rootMenus.tools.submenu.push(menuItemDic.linkToNote);
|
||||
rootMenus.tools.submenu.push(menuItemDic.openMasterPasswordDialog);
|
||||
|
||||
for (const view of props.pluginMenuItems) {
|
||||
@@ -1138,7 +1147,7 @@ function MenuBar(props: Props): any {
|
||||
|
||||
|
||||
const mapStateToProps = (state: AppState): Partial<Props> => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state);
|
||||
const whenClauseContext = stateToWhenClauseContext(state, { windowId: state.windowId });
|
||||
|
||||
const secondaryWindowFocused = state.windowId !== defaultWindowId;
|
||||
|
||||
@@ -1164,6 +1173,7 @@ const mapStateToProps = (state: AppState): Partial<Props> => {
|
||||
pluginMenus: stateUtils.selectArrayShallow({ array: pluginUtils.viewsByType(state.pluginService.plugins, 'menu') }, 'menuBar.pluginMenus'),
|
||||
['spellChecker.languages']: state.settings['spellChecker.languages'],
|
||||
['spellChecker.enabled']: state.settings['spellChecker.enabled'],
|
||||
markdownEditorVisible: whenClauseContext.markdownEditorVisible,
|
||||
plugins: state.pluginService.plugins,
|
||||
customCss: state.customViewerCss,
|
||||
profileConfig: state.profileConfig,
|
||||
|
@@ -383,10 +383,13 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
|
||||
// Update the editor's value
|
||||
useEffect(() => {
|
||||
if (editorRef.current?.updateBody(props.content)) {
|
||||
// Include the noteId in the update props to give plugins access
|
||||
// to the current note ID.
|
||||
const updateProps = { noteId: props.noteId };
|
||||
if (editorRef.current?.updateBody(props.content, updateProps)) {
|
||||
editorRef.current?.clearHistory();
|
||||
}
|
||||
}, [props.content]);
|
||||
}, [props.content, props.noteId]);
|
||||
|
||||
const renderEditor = () => {
|
||||
return (
|
||||
@@ -394,6 +397,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
<Editor
|
||||
style={styles.editor}
|
||||
initialText={props.content}
|
||||
initialNoteId={props.noteId}
|
||||
ref={editorRef}
|
||||
settings={editorSettings}
|
||||
pluginStates={props.plugins}
|
||||
|
@@ -129,6 +129,14 @@ const useEditorCommands = (props: Props) => {
|
||||
props.webviewRef.current.send('focus');
|
||||
}
|
||||
},
|
||||
'viewer.focus': () => {
|
||||
if (props.visiblePanes.includes('viewer')) {
|
||||
const editorCursorLine = editorRef.current.getCursor().line;
|
||||
props.webviewRef.current.focusLine(editorCursorLine);
|
||||
} else {
|
||||
logger.info('Viewer not focused (not visible).');
|
||||
}
|
||||
},
|
||||
search: () => {
|
||||
return editorRef.current.execCommand(EditorCommandType.ShowSearch);
|
||||
},
|
||||
|
@@ -617,6 +617,13 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
background: none;
|
||||
background-color: ${theme.backgroundColor3} !important;
|
||||
}
|
||||
|
||||
.tox .tox-tbtn,
|
||||
.tox .tox-tbtn button,
|
||||
.tox .tox-split-button,
|
||||
.tox .tox-split-button button {
|
||||
margin: 0 !important;
|
||||
}
|
||||
`));
|
||||
|
||||
return () => {
|
||||
@@ -673,7 +680,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
// we create small groups of just one button towards the end.
|
||||
|
||||
const toolbar = [
|
||||
'bold', 'italic', 'joplinHighlight', 'joplinStrikethrough', 'formattingExtras', '|',
|
||||
'bold', 'italic', 'joplinHighlight', 'joplinStrikethrough', '|',
|
||||
'joplinInsert', 'joplinSup', 'joplinSub', 'forecolor', '|',
|
||||
'link', 'joplinInlineCode', 'joplinCodeBlock', 'joplinAttach', '|',
|
||||
'bullist', 'numlist', 'joplinChecklist', '|',
|
||||
'h1', 'h2', 'h3', '|',
|
||||
@@ -1097,6 +1105,13 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
// Meta+P is bound by default to print by TinyMCE. It can be unbound, but it seems necessary
|
||||
// to do so after the editor loads. Meta+P should be able to trigger Joplin built-in shortcuts.
|
||||
editor.shortcuts.remove('Meta+P');
|
||||
}, [editor]);
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Handle onChange event
|
||||
// -----------------------------------------------------------------------------------------
|
||||
@@ -1344,7 +1359,9 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
editor.on(TinyMceEditorEvents.KeyUp, onKeyUp);
|
||||
editor.on(TinyMceEditorEvents.KeyDown, onKeyDown);
|
||||
editor.on(TinyMceEditorEvents.KeyPress, onKeypress);
|
||||
editor.on(TinyMceEditorEvents.Paste, onPaste);
|
||||
// Passing `true` adds the listener to the front of the listener list.
|
||||
// This allows overriding TinyMCE's built-in paste handler with .preventDefault.
|
||||
editor.on(TinyMceEditorEvents.Paste, onPaste, true);
|
||||
editor.on(TinyMceEditorEvents.PasteAsText, onPasteAsText);
|
||||
editor.on(TinyMceEditorEvents.Copy, onCopy);
|
||||
// `compositionend` means that a user has finished entering a Chinese
|
||||
|
@@ -60,14 +60,12 @@ export default function(editor: any) {
|
||||
});
|
||||
}
|
||||
|
||||
const items: string[] = definitions.filter(d => !!d.grouped).map(d => d.name);
|
||||
|
||||
// Additional built-in buttons to show in the formatting sub-menu:
|
||||
items.push('forecolor');
|
||||
|
||||
editor.ui.registry.addGroupToolbarButton('formattingExtras', {
|
||||
icon: 'image-options',
|
||||
tooltip: _('Formatting'),
|
||||
items: items.join(' '),
|
||||
});
|
||||
// Old code to format a group of buttons into a dropdown
|
||||
// const items: string[] = definitions.filter(d => !!d.grouped).map(d => d.name);
|
||||
// items.push('forecolor');
|
||||
// editor.ui.registry.addGroupToolbarButton('formattingExtras', {
|
||||
// icon: 'image-options',
|
||||
// tooltip: _('Formatting'),
|
||||
// items: items.join(' '),
|
||||
// });
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@ import { menuItems } from '../../../utils/contextMenu';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import type { Event as ElectronEvent } from 'electron';
|
||||
import type { Event as ElectronEvent, MenuItemConstructorOptions } from 'electron';
|
||||
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { TinyMceEditorEvents } from './types';
|
||||
@@ -17,6 +17,7 @@ import { Editor } from 'tinymce';
|
||||
import { EditDialogControl } from './useEditDialog';
|
||||
import { Dispatch } from 'redux';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import type { MenuItem as MenuItemType } from 'electron';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
@@ -137,13 +138,20 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
|
||||
event.preventDefault();
|
||||
|
||||
const menu = new Menu();
|
||||
const menuItems = [];
|
||||
const menuItems: MenuItemType[] = [];
|
||||
const toMenuItems = (specs: MenuItemConstructorOptions[]) => {
|
||||
return specs.map(spec => new MenuItem(spec));
|
||||
};
|
||||
|
||||
menuItems.push(...makeEditableMenuItems(element));
|
||||
menuItems.push(...makeMainMenuItems(element));
|
||||
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
|
||||
menuItems.push(...spellCheckerMenuItems);
|
||||
menuItems.push(...menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu));
|
||||
menuItems.push(
|
||||
...toMenuItems(spellCheckerMenuItems),
|
||||
);
|
||||
menuItems.push(
|
||||
...toMenuItems(menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu)),
|
||||
);
|
||||
|
||||
for (const item of menuItems) {
|
||||
menu.append(item);
|
||||
|
@@ -52,10 +52,8 @@ import Logger from '@joplin/utils/Logger';
|
||||
import usePluginEditorView from './utils/usePluginEditorView';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { WindowIdContext } from '../NewWindowOrIFrame';
|
||||
import { EditorActivationCheckFilterObject } from '@joplin/lib/services/plugins/api/types';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import WebviewController from '@joplin/lib/services/plugins/WebviewController';
|
||||
import AsyncActionQueue, { IntervalType } from '@joplin/lib/AsyncActionQueue';
|
||||
import EditorPluginHandler from '@joplin/lib/services/plugins/EditorPluginHandler';
|
||||
import useResourceUnwatcher from './utils/useResourceUnwatcher';
|
||||
import StatusBar from './StatusBar';
|
||||
|
||||
@@ -72,15 +70,6 @@ const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
|
||||
const onDragOver: React.DragEventHandler = event => event.preventDefault();
|
||||
let editorIdCounter = 0;
|
||||
|
||||
const makeNoteUpdateAction = (shownEditorViewIds: string[]) => {
|
||||
return async () => {
|
||||
for (const viewId of shownEditorViewIds) {
|
||||
const controller = PluginService.instance().viewControllerByViewId(viewId) as WebviewController;
|
||||
if (controller) controller.emitUpdate();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
function NoteEditorContent(props: NoteEditorProps) {
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
|
||||
@@ -90,7 +79,10 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const titleInputRef = useRef<HTMLInputElement>();
|
||||
const isMountedRef = useRef(true);
|
||||
const noteSearchBarRef = useRef(null);
|
||||
const viewUpdateAsyncQueue_ = useRef<AsyncActionQueue>(new AsyncActionQueue(100, IntervalType.Fixed));
|
||||
|
||||
const editorPluginHandler = useMemo(() => {
|
||||
return new EditorPluginHandler(PluginService.instance());
|
||||
}, []);
|
||||
|
||||
const shownEditorViewIds = props['plugins.shownEditorViewIds'];
|
||||
|
||||
@@ -114,25 +106,15 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
|
||||
const effectiveNoteId = useEffectiveNoteId(props);
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
useAsyncEffect(async (_event) => {
|
||||
if (!props.startupPluginsLoaded) return;
|
||||
|
||||
let filterObject: EditorActivationCheckFilterObject = {
|
||||
activatedEditors: [],
|
||||
};
|
||||
filterObject = await eventManager.filterEmit('editorActivationCheck', filterObject);
|
||||
if (event.cancelled) return;
|
||||
|
||||
for (const editor of filterObject.activatedEditors) {
|
||||
const controller = PluginService.instance().pluginById(editor.pluginId).viewController(editor.viewId) as WebviewController;
|
||||
controller.setActive(editor.isActive);
|
||||
}
|
||||
}, [effectiveNoteId, props.startupPluginsLoaded]);
|
||||
await editorPluginHandler.emitActivationCheck();
|
||||
}, [effectiveNoteId, editorPluginHandler, props.startupPluginsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.startupPluginsLoaded) return;
|
||||
viewUpdateAsyncQueue_.current.push(makeNoteUpdateAction(shownEditorViewIds));
|
||||
}, [effectiveNoteId, shownEditorViewIds, props.startupPluginsLoaded]);
|
||||
editorPluginHandler.emitUpdate(shownEditorViewIds);
|
||||
}, [effectiveNoteId, editorPluginHandler, shownEditorViewIds, props.startupPluginsLoaded]);
|
||||
|
||||
const { editorPlugin, editorView } = usePluginEditorView(props.plugins, shownEditorViewIds);
|
||||
const builtInEditorVisible = !editorPlugin;
|
||||
|
@@ -38,6 +38,7 @@ const incompatiblePluginIds = [
|
||||
'ylc395.noteLinkSystem',
|
||||
'outline',
|
||||
'joplin.plugin.cmoptions',
|
||||
'com.asdibiase.joplin-languagetool',
|
||||
// cSpell:enable
|
||||
];
|
||||
|
||||
|
@@ -0,0 +1,22 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { FocusElementOptions } from '../../../commands/focusElement';
|
||||
import { WindowCommandDependencies } from '../utils/types';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'focusElementNoteViewer',
|
||||
label: () => _('Note viewer'),
|
||||
parentLabel: () => _('Focus'),
|
||||
};
|
||||
|
||||
export const runtime = (dependencies: WindowCommandDependencies): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: unknown, options?: FocusElementOptions) => {
|
||||
await dependencies.editorRef.current.execCommand({
|
||||
name: 'viewer.focus',
|
||||
value: options,
|
||||
});
|
||||
},
|
||||
enabledCondition: 'markdownEditorVisible',
|
||||
};
|
||||
};
|
@@ -1,6 +1,7 @@
|
||||
// AUTO-GENERATED using `gulp buildScriptIndexes`
|
||||
import * as focusElementNoteBody from './focusElementNoteBody';
|
||||
import * as focusElementNoteTitle from './focusElementNoteTitle';
|
||||
import * as focusElementNoteViewer from './focusElementNoteViewer';
|
||||
import * as focusElementToolbar from './focusElementToolbar';
|
||||
import * as pasteAsText from './pasteAsText';
|
||||
import * as showLocalSearch from './showLocalSearch';
|
||||
@@ -9,6 +10,7 @@ import * as showRevisions from './showRevisions';
|
||||
const index: any[] = [
|
||||
focusElementNoteBody,
|
||||
focusElementNoteTitle,
|
||||
focusElementNoteViewer,
|
||||
focusElementToolbar,
|
||||
pasteAsText,
|
||||
showLocalSearch,
|
||||
|
@@ -163,6 +163,9 @@ const declarations: CommandDeclaration[] = [
|
||||
{
|
||||
name: 'editor.execCommand',
|
||||
},
|
||||
{
|
||||
name: 'viewer.focus',
|
||||
},
|
||||
];
|
||||
|
||||
export default declarations;
|
||||
|
@@ -1,15 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getActivePluginEditorView';
|
||||
import getShownPluginEditorView from '@joplin/lib/services/plugins/utils/getShownPluginEditorView';
|
||||
|
||||
// If a plugin editor should be shown for the current note, this function will return the plugin and
|
||||
// associated view.
|
||||
export default (plugins: PluginStates, shownEditorViewIds: string[]) => {
|
||||
return useMemo(() => {
|
||||
const { editorPlugin, editorView } = getActivePluginEditorView(plugins);
|
||||
if (editorView) {
|
||||
if (!shownEditorViewIds.includes(editorView.id)) return { editorPlugin: null, editorView: null };
|
||||
}
|
||||
return { editorPlugin, editorView };
|
||||
return getShownPluginEditorView(plugins, shownEditorViewIds);
|
||||
}, [plugins, shownEditorViewIds]);
|
||||
};
|
||||
|
@@ -10,6 +10,7 @@ const commandsWithDependencies = [
|
||||
require('../commands/showLocalSearch'),
|
||||
require('../commands/focusElementNoteTitle'),
|
||||
require('../commands/focusElementNoteBody'),
|
||||
require('../commands/focusElementNoteViewer'),
|
||||
require('../commands/focusElementToolbar'),
|
||||
require('../commands/pasteAsText'),
|
||||
];
|
||||
|
@@ -118,6 +118,8 @@ const NoteList = (props: Props) => {
|
||||
props.notes.length,
|
||||
listRenderer.flow,
|
||||
itemsPerLine,
|
||||
props.showCompletedTodos,
|
||||
props.uncompletedTodosOnTop,
|
||||
);
|
||||
|
||||
useItemCss(listRenderer.itemCss);
|
||||
|
@@ -23,6 +23,8 @@ const useOnKeyDown = (
|
||||
noteCount: number,
|
||||
flow: ItemFlow,
|
||||
itemsPerLine: number,
|
||||
showCompletedTodos: boolean,
|
||||
uncompletedTodosOnTop: boolean,
|
||||
) => {
|
||||
const scrollNoteIndex = useCallback((visibleItemCount: number, key: KeyboardEventKey, ctrlKey: boolean, metaKey: boolean, noteIndex: number) => {
|
||||
if (flow === ItemFlow.TopToBottom) {
|
||||
@@ -142,13 +144,32 @@ const useOnKeyDown = (
|
||||
const todos = selectedNotes.filter(n => !!n.is_todo);
|
||||
if (!todos.length) return;
|
||||
|
||||
const firstNoteIndex = notes.findIndex(n => n.id === todos[0].id);
|
||||
let nextSelectedNoteIndex = firstNoteIndex + 1;
|
||||
if (nextSelectedNoteIndex > notes.length - 1) nextSelectedNoteIndex = notes.length - 1;
|
||||
const nextSelectedNote = nextSelectedNoteIndex >= 0 ? notes[nextSelectedNoteIndex] : todos[0];
|
||||
|
||||
for (let i = 0; i < todos.length; i++) {
|
||||
const toggledTodo = Note.toggleTodoCompleted(todos[i]);
|
||||
await Note.save(toggledTodo);
|
||||
}
|
||||
|
||||
// When the settings `uncompletedTodosOnTop` or `showCompletedTodos` are enabled, the
|
||||
// note that got set as completed or uncompleted is going to disappear from view,
|
||||
// possibly hidden or moved to the top or bottom of the note list. It is assumed that
|
||||
// the user does not want to keep that note selected since the to-do is indeed
|
||||
// "completed". And by keeping that selection, the cursor would jump, making you lose
|
||||
// context if you have multiple to-dos that need to be ticked. For that reason we set
|
||||
// the selection to the next note in the list, which also ensures that the scroll
|
||||
// position doesn't change. This is the same behaviour as when deleting a note.
|
||||
const maintainScrollPosition = !showCompletedTodos || uncompletedTodosOnTop;
|
||||
|
||||
if (maintainScrollPosition) {
|
||||
dispatch({ type: 'NOTE_SELECT', noteId: nextSelectedNote.id });
|
||||
}
|
||||
|
||||
dispatch({ type: 'NOTE_SORT' });
|
||||
focusNote(todos[0].id);
|
||||
if (!maintainScrollPosition) focusNote(todos[0].id);
|
||||
const wasCompleted = !!todos[0].todo_completed;
|
||||
announceForAccessibility(!wasCompleted ? _('Complete') : _('Incomplete'));
|
||||
}
|
||||
@@ -171,7 +192,7 @@ const useOnKeyDown = (
|
||||
type: 'NOTE_SELECT_ALL',
|
||||
});
|
||||
}
|
||||
}, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, activeNoteId, dispatch, flow, itemsPerLine]);
|
||||
}, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, activeNoteId, dispatch, flow, itemsPerLine, showCompletedTodos, uncompletedTodosOnTop]);
|
||||
|
||||
|
||||
return onKeyDown;
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { useCallback, useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useState, useRef, useMemo } from 'react';
|
||||
|
||||
const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, listSize: Size, listRef: React.MutableRefObject<HTMLDivElement>) => {
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
@@ -29,36 +28,36 @@ const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, list
|
||||
// but still fails now and then. Setting it after 500ms would probably work
|
||||
// reliably but it's too slow so it makes sense to do it in an interval.
|
||||
|
||||
const setScrollTopLikeYouMeanItTimer = useRef(null);
|
||||
const setScrollTopLikeYouMeanItStartTime = useRef(0);
|
||||
const setScrollTopLikeYouMeanIt = useCallback((newScrollTop: number) => {
|
||||
if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
setScrollTopLikeYouMeanItStartTime.current = Date.now();
|
||||
// const setScrollTopLikeYouMeanItTimer = useRef(null);
|
||||
// const setScrollTopLikeYouMeanItStartTime = useRef(0);
|
||||
// const setScrollTopLikeYouMeanIt = useCallback((newScrollTop: number) => {
|
||||
// if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
// setScrollTopLikeYouMeanItStartTime.current = Date.now();
|
||||
|
||||
listRef.current.scrollTop = newScrollTop;
|
||||
lastScrollSetTime.current = Date.now();
|
||||
// listRef.current.scrollTop = newScrollTop;
|
||||
// lastScrollSetTime.current = Date.now();
|
||||
|
||||
setScrollTopLikeYouMeanItTimer.current = shim.setInterval(() => {
|
||||
if (!listRef.current) {
|
||||
shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
setScrollTopLikeYouMeanItTimer.current = null;
|
||||
return;
|
||||
}
|
||||
// setScrollTopLikeYouMeanItTimer.current = shim.setInterval(() => {
|
||||
// if (!listRef.current) {
|
||||
// shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
// setScrollTopLikeYouMeanItTimer.current = null;
|
||||
// return;
|
||||
// }
|
||||
|
||||
listRef.current.scrollTop = newScrollTop;
|
||||
lastScrollSetTime.current = Date.now();
|
||||
// listRef.current.scrollTop = newScrollTop;
|
||||
// lastScrollSetTime.current = Date.now();
|
||||
|
||||
if (Date.now() - setScrollTopLikeYouMeanItStartTime.current > 1000) {
|
||||
shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
setScrollTopLikeYouMeanItTimer.current = null;
|
||||
}
|
||||
}, 10);
|
||||
}, [listRef]);
|
||||
// if (Date.now() - setScrollTopLikeYouMeanItStartTime.current > 1000) {
|
||||
// shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
// setScrollTopLikeYouMeanItTimer.current = null;
|
||||
// }
|
||||
// }, 10);
|
||||
// }, [listRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
setScrollTopLikeYouMeanItTimer.current = null;
|
||||
}, []);
|
||||
// useEffect(() => {
|
||||
// if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
|
||||
// setScrollTopLikeYouMeanItTimer.current = null;
|
||||
// }, []);
|
||||
|
||||
const makeItemIndexVisible = useCallback((itemIndex: number) => {
|
||||
const lineTopFloat = scrollTop / itemSize.height;
|
||||
@@ -83,13 +82,17 @@ const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, list
|
||||
if (newScrollTop > maxScrollTop) newScrollTop = maxScrollTop;
|
||||
|
||||
setScrollTop(newScrollTop);
|
||||
setScrollTopLikeYouMeanIt(newScrollTop);
|
||||
}, [itemsPerLine, noteCount, itemSize.height, scrollTop, listSize.height, maxScrollTop, setScrollTopLikeYouMeanIt]);
|
||||
listRef.current.scrollTop = newScrollTop;
|
||||
lastScrollSetTime.current = Date.now();
|
||||
// setScrollTopLikeYouMeanIt(newScrollTop);
|
||||
}, [itemsPerLine, noteCount, itemSize.height, scrollTop, listSize.height, maxScrollTop, listRef]); // , setScrollTopLikeYouMeanIt]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onScroll = useCallback((event: any) => {
|
||||
// console.info('ON SCROLL', event.target.scrollTop, 'Ignore:', Date.now() - lastScrollSetTime.current < 500);
|
||||
|
||||
// Ignore the scroll event if it has just been set programmatically.
|
||||
if (Date.now() - lastScrollSetTime.current < 500) return;
|
||||
if (Date.now() - lastScrollSetTime.current < 10) return;
|
||||
setScrollTop(event.target.scrollTop);
|
||||
}, []);
|
||||
|
||||
|
@@ -40,6 +40,12 @@ const useVisibleRange = (itemsPerLine: number, scrollTop: number, listSize: Size
|
||||
return Math.ceil(noteCount / itemsPerLine);
|
||||
}, [noteCount, itemsPerLine]);
|
||||
|
||||
// Note: Leave this here to test the note list scroll behaviour. Also add "item.index" to the
|
||||
// rows in defaultListRenderer to check whether the value here matches what's being displayed.
|
||||
// `useScroll` can also be changed to display the effective scroll value.
|
||||
|
||||
// console.info('=======================================');
|
||||
// console.info('scrollTop', scrollTop);
|
||||
// console.info('itemsPerLine', itemsPerLine);
|
||||
// console.info('listSize.height', listSize.height);
|
||||
// console.info('itemSize.height', itemSize.height);
|
||||
@@ -52,6 +58,7 @@ const useVisibleRange = (itemsPerLine: number, scrollTop: number, listSize: Size
|
||||
// console.info('endLineIndex', endLineIndex);
|
||||
// console.info('totalLineCount', totalLineCount);
|
||||
// console.info('visibleItemCount', visibleItemCount);
|
||||
// console.info('=======================================');
|
||||
|
||||
return [startNoteIndex, endNoteIndex, startLineIndex, endLineIndex, totalLineCount, visibleItemCount];
|
||||
};
|
||||
|
@@ -37,6 +37,9 @@ interface State {
|
||||
formNote: FormNote;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
editedValue: any;
|
||||
isValid: {
|
||||
location: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const uniqueId = (key: string) => `note-properties-dialog-${key}`;
|
||||
@@ -60,6 +63,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
|
||||
this.revisionsLink_click = this.revisionsLink_click.bind(this);
|
||||
this.buttonRow_click = this.buttonRow_click.bind(this);
|
||||
this.locationOnChange = this.locationOnChange.bind(this);
|
||||
this.okButton = React.createRef();
|
||||
this.inputRef = React.createRef();
|
||||
|
||||
@@ -67,6 +71,9 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
formNote: null,
|
||||
editedKey: null,
|
||||
editedValue: null,
|
||||
isValid: {
|
||||
location: true,
|
||||
},
|
||||
};
|
||||
|
||||
this.keyToLabel_ = {
|
||||
@@ -195,6 +202,17 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
borderColor: theme.dividerColor,
|
||||
};
|
||||
|
||||
this.styles_.invalidInput = {
|
||||
border: '1px solid',
|
||||
borderColor: theme.colorWarn,
|
||||
};
|
||||
|
||||
this.styles_.invalidMessage = {
|
||||
marginTop: '0.3em',
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize * 0.9,
|
||||
};
|
||||
|
||||
return this.styles_;
|
||||
}
|
||||
|
||||
@@ -276,6 +294,24 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
});
|
||||
}
|
||||
|
||||
public async locationOnChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
this.setState({ editedValue: event.target.value });
|
||||
if (!event.target.value) {
|
||||
this.setState({ isValid: { ...this.state.isValid, location: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.value.includes(',')) {
|
||||
const [lat, log] = event.target.value.split(',');
|
||||
if (parseFloat(lat) < 90 && parseFloat(lat) > -90 && parseFloat(log) < 180 && parseFloat(log) > -180) {
|
||||
this.setState({ isValid: { ...this.state.isValid, location: true } });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ isValid: { ...this.state.isValid, location: false } });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public createNoteField(key: keyof FormNote, value: any) {
|
||||
const styles = this.styles(this.props.themeId);
|
||||
@@ -288,8 +324,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
let editCompIcon = null;
|
||||
let editComDescription = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onKeyDown = (event: any) => {
|
||||
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.keyCode === 13) {
|
||||
void this.saveProperty();
|
||||
} else if (event.keyCode === 27) {
|
||||
@@ -315,6 +350,30 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
};
|
||||
editCompIcon = 'fa-save';
|
||||
editComDescription = _('Save changes');
|
||||
} else if (this.state.editedKey === 'location') {
|
||||
controlComp = (
|
||||
<React.Fragment>
|
||||
<input
|
||||
defaultValue={value}
|
||||
type="text"
|
||||
ref={this.inputRef}
|
||||
onChange={this.locationOnChange}
|
||||
onKeyDown={event => onKeyDown(event)}
|
||||
style={this.state.isValid.location ? styles.input : { ...styles.input, ...styles.invalidInput }}
|
||||
id={uniqueId(key)}
|
||||
name={uniqueId(key)}
|
||||
aria-invalid={!this.state.isValid.location}
|
||||
/>
|
||||
{
|
||||
this.state.isValid.location ? null
|
||||
: <React.Fragment>
|
||||
<div aria-live='polite' style={styles.invalidMessage}>
|
||||
{_('Invalid format. E.g.: 48.8581372, 2.2926735')}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
controlComp = (
|
||||
<input
|
||||
|
@@ -28,6 +28,7 @@ export interface NoteViewerControl {
|
||||
domReady(): boolean;
|
||||
setHtml(html: string, options: SetHtmlOptions): void;
|
||||
send(channel: string, arg0?: unknown, arg1?: unknown): void;
|
||||
focusLine(editorLine: number): void;
|
||||
focus(): void;
|
||||
hasFocus(): boolean;
|
||||
}
|
||||
@@ -107,6 +108,10 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
|
||||
win.postMessage({ target: 'webview', name: 'focus', data: {} }, '*');
|
||||
}
|
||||
|
||||
if (channel === 'focusLine') {
|
||||
win.postMessage({ target: 'webview', name: 'focusLine', data: { line: arg0 } }, '*');
|
||||
}
|
||||
|
||||
// External code should use .setHtml (rather than send('setHtml', ...))
|
||||
if (channel === 'setHtml') {
|
||||
win.postMessage({ target: 'webview', name: 'setHtml', data: { html: arg0, options: arg1 } }, '*');
|
||||
@@ -139,6 +144,15 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
|
||||
hasFocus: () => {
|
||||
return webviewRef.current?.contains(parentDoc.activeElement);
|
||||
},
|
||||
focusLine: (lineNumber: number) => {
|
||||
if (webviewRef.current) {
|
||||
focus('NoteTextViewer::focusLine', webviewRef.current);
|
||||
// A timeout seems necessary after focusing the viewer to prevent focus from jumping to the top
|
||||
setTimeout(() => {
|
||||
result.send('focusLine', lineNumber);
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}, [parentDoc]);
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
|
||||
import areAllFoldersCollapsed from '@joplin/lib/models/utils/areAllFoldersCollapsed';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import { Dispatch } from 'redux';
|
||||
@@ -41,6 +42,10 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
listItems: listItems,
|
||||
});
|
||||
|
||||
const allFoldersCollapsed = useMemo(() => {
|
||||
return areAllFoldersCollapsed(props.folders, props.collapsedFolderIds);
|
||||
}, [props.collapsedFolderIds, props.folders]);
|
||||
|
||||
const listContainerRef = useRef<HTMLDivElement|null>(null);
|
||||
const onRenderItem = useOnRenderItem({
|
||||
...props,
|
||||
@@ -67,7 +72,7 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
const listHeight = useElementHeight(itemListContainer);
|
||||
const listStyle = useMemo(() => ({ height: listHeight }), [listHeight]);
|
||||
|
||||
const onRenderContentWrapper = useOnRenderListWrapper({ selectedIndex, onKeyDown: onKeyEventHandler });
|
||||
const onRenderContentWrapper = useOnRenderListWrapper({ allFoldersCollapsed, selectedIndex, onKeyDown: onKeyEventHandler });
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@@ -417,6 +417,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
key={item.key}
|
||||
anchorRef={anchorRef}
|
||||
selected={selected}
|
||||
item={item}
|
||||
index={index}
|
||||
itemCount={itemCount}
|
||||
/>;
|
||||
@@ -425,7 +426,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
<ListItemWrapper
|
||||
key={item.key}
|
||||
containerRef={anchorRef}
|
||||
depth={0}
|
||||
depth={1}
|
||||
selected={selected}
|
||||
itemIndex={index}
|
||||
itemCount={itemCount}
|
||||
|
@@ -6,16 +6,40 @@ import CommandService from '@joplin/lib/services/CommandService';
|
||||
interface Props {
|
||||
selectedIndex: number;
|
||||
onKeyDown: React.KeyboardEventHandler;
|
||||
allFoldersCollapsed: boolean;
|
||||
}
|
||||
|
||||
const onAddFolderButtonClick = () => {
|
||||
void CommandService.instance().execute('newFolder');
|
||||
};
|
||||
|
||||
const onToggleAllFolders = (allFoldersCollapsed: boolean) => {
|
||||
void CommandService.instance().execute('toggleAllFolders', !allFoldersCollapsed);
|
||||
};
|
||||
|
||||
interface CollapseExpandAllButtonProps {
|
||||
allFoldersCollapsed: boolean;
|
||||
}
|
||||
|
||||
const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
|
||||
// To allow it to be accessed by accessibility tools, the toggle button
|
||||
// is not included in the portion of the list with role='tree'.
|
||||
const icon = props.allFoldersCollapsed ? 'far fa-caret-square-right' : 'far fa-caret-square-down';
|
||||
const label = props.allFoldersCollapsed ? _('Expand all notebooks') : _('Collapse all notebooks');
|
||||
|
||||
return <button onClick={() => onToggleAllFolders(props.allFoldersCollapsed)} className='sidebar-header-button -collapseall'>
|
||||
<i
|
||||
aria-label={label}
|
||||
role='img'
|
||||
className={icon}
|
||||
/>
|
||||
</button>;
|
||||
};
|
||||
|
||||
const NewFolderButton = () => {
|
||||
// To allow it to be accessed by accessibility tools, the new folder button
|
||||
// is not included in the portion of the list with role='tree'.
|
||||
return <button onClick={onAddFolderButtonClick} className='new-folder-button'>
|
||||
return <button onClick={onAddFolderButtonClick} className='sidebar-header-button -newfolder'>
|
||||
<i
|
||||
aria-label={_('New notebook')}
|
||||
role='img'
|
||||
@@ -24,22 +48,23 @@ const NewFolderButton = () => {
|
||||
</button>;
|
||||
};
|
||||
|
||||
const useOnRenderListWrapper = ({ selectedIndex, onKeyDown }: Props) => {
|
||||
const useOnRenderListWrapper = (props: Props) => {
|
||||
return useCallback((listItems: React.ReactNode[]) => {
|
||||
const listHasValidSelection = selectedIndex >= 0;
|
||||
const listHasValidSelection = props.selectedIndex >= 0;
|
||||
const allowContainerFocus = !listHasValidSelection;
|
||||
return <>
|
||||
<CollapseExpandAllButton allFoldersCollapsed={props.allFoldersCollapsed}/>
|
||||
<NewFolderButton/>
|
||||
<div
|
||||
role='tree'
|
||||
className='sidebar-list-items-wrapper'
|
||||
tabIndex={allowContainerFocus ? 0 : undefined}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDown={props.onKeyDown}
|
||||
>
|
||||
{...listItems}
|
||||
</div>
|
||||
</>;
|
||||
}, [selectedIndex, onKeyDown]);
|
||||
}, [props.selectedIndex, props.onKeyDown, props.allFoldersCollapsed]);
|
||||
};
|
||||
|
||||
export default useOnRenderListWrapper;
|
||||
|
@@ -12,7 +12,6 @@ interface Props {
|
||||
updateSelectedIndex: SetSelectedIndexCallback;
|
||||
}
|
||||
|
||||
|
||||
const isToggleShortcut = (keyCode: string, selectedItem: ListItem, collapsedFolderIds: string[]) => {
|
||||
if (selectedItem.kind !== ListItemType.Header && selectedItem.kind !== ListItemType.Folder) {
|
||||
return false;
|
||||
@@ -22,6 +21,10 @@ const isToggleShortcut = (keyCode: string, selectedItem: ListItem, collapsedFold
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selectedItem.hasChildren) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyCode === 'Space') {
|
||||
return true;
|
||||
}
|
||||
@@ -30,6 +33,22 @@ const isToggleShortcut = (keyCode: string, selectedItem: ListItem, collapsedFold
|
||||
return (keyCode === 'ArrowRight') === isCollapsed;
|
||||
};
|
||||
|
||||
const getParentOffset = (childIndex: number, listItems: ListItem[]): number|null => {
|
||||
const childItem = listItems[childIndex];
|
||||
const targetDepth = childItem.depth - 1;
|
||||
|
||||
let indexChange = 0;
|
||||
for (let i = childIndex; i >= 0; i--) {
|
||||
const otherItem = listItems[i];
|
||||
if (otherItem.depth === targetDepth) {
|
||||
return indexChange;
|
||||
}
|
||||
indexChange --;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const useOnSidebarKeyDownHandler = (props: Props) => {
|
||||
const { updateSelectedIndex, listItems, selectedIndex, collapsedFolderIds, dispatch } = props;
|
||||
|
||||
@@ -48,12 +67,21 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
|
||||
} else if (selectedItem.kind === ListItemType.Header) {
|
||||
toggleHeader(selectedItem.id);
|
||||
}
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
|
||||
event.preventDefault();
|
||||
} else if (selectedItem && event.code === 'ArrowLeft') { // Jump to parent
|
||||
const isFolderWithParent = selectedItem.kind === ListItemType.Folder && selectedItem.folder.parent_id;
|
||||
// For now, only allow this shortcut for folders with parents -- jumping to the tags or
|
||||
// folders headers could be confusing.
|
||||
if (isFolderWithParent) {
|
||||
indexChange = getParentOffset(selectedIndex, listItems) ?? 0;
|
||||
}
|
||||
} else if (selectedItem?.hasChildren && event.code === 'ArrowRight') { // Jump to first child
|
||||
indexChange = 1;
|
||||
} else if (event.code === 'ArrowUp') {
|
||||
indexChange = -1;
|
||||
} else if (event.code === 'ArrowDown') {
|
||||
indexChange = 1;
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
|
||||
event.preventDefault();
|
||||
} else if (event.code === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void CommandService.instance().execute('focusElement', 'noteList');
|
||||
|
@@ -20,6 +20,8 @@ const useSidebarListData = (props: Props): ListItem[] => {
|
||||
kind: ListItemType.Tag,
|
||||
tag,
|
||||
key: tag.id,
|
||||
depth: 1,
|
||||
hasChildren: false,
|
||||
};
|
||||
});
|
||||
}, [props.tags]);
|
||||
@@ -38,7 +40,9 @@ const useSidebarListData = (props: Props): ListItem[] => {
|
||||
kind: ListItemType.Folder,
|
||||
folder,
|
||||
hasChildren,
|
||||
depth,
|
||||
// The toplevel headers have depth 1, so the toplevel notebook needs
|
||||
// depth 2.
|
||||
depth: depth + 1,
|
||||
key: folder.id,
|
||||
};
|
||||
});
|
||||
@@ -57,11 +61,13 @@ const useSidebarListData = (props: Props): ListItem[] => {
|
||||
['data-folder-id']: '',
|
||||
},
|
||||
supportsFolderDrop: true,
|
||||
depth: 1,
|
||||
hasChildren: folderItems.items.length > 0,
|
||||
};
|
||||
const foldersSectionContent: ListItem[] = props.folderHeaderIsExpanded ? [
|
||||
{ kind: ListItemType.AllNotes, key: 'all-notes' },
|
||||
{ kind: ListItemType.AllNotes, key: 'all-notes', depth: 2, hasChildren: false },
|
||||
...folderItems.items,
|
||||
{ kind: ListItemType.Spacer, key: 'after-folders-spacer' },
|
||||
{ kind: ListItemType.Spacer, key: 'after-folders-spacer', depth: 1, hasChildren: false },
|
||||
] : [];
|
||||
|
||||
const tagsHeader: HeaderListItem = {
|
||||
@@ -74,6 +80,8 @@ const useSidebarListData = (props: Props): ListItem[] => {
|
||||
onClick: toggleHeader,
|
||||
extraProps: { },
|
||||
supportsFolderDrop: false,
|
||||
depth: 1,
|
||||
hasChildren: tagItems.items.length > 0,
|
||||
};
|
||||
const tagsSectionContent: ListItem[] = props.tagHeaderIsExpanded ? tagItems.items : [];
|
||||
|
||||
|
@@ -11,6 +11,7 @@ import { _ } from '@joplin/lib/locale';
|
||||
import { connect } from 'react-redux';
|
||||
import EmptyExpandLink from './EmptyExpandLink';
|
||||
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
|
||||
import { ListItem } from '../types';
|
||||
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
@@ -20,6 +21,7 @@ interface Props {
|
||||
dispatch: Dispatch;
|
||||
anchorRef: ListItemRef;
|
||||
selected: boolean;
|
||||
item: ListItem;
|
||||
index: number;
|
||||
itemCount: number;
|
||||
}
|
||||
@@ -53,7 +55,7 @@ const AllNotesItem: React.FC<Props> = props => {
|
||||
containerRef={props.anchorRef}
|
||||
key="allNotesHeader"
|
||||
selected={props.selected}
|
||||
depth={1}
|
||||
depth={props.item.depth}
|
||||
className={'list-item-container list-item-depth-0 all-notes'}
|
||||
highlightOnHover={true}
|
||||
itemIndex={props.index}
|
||||
|
@@ -52,7 +52,7 @@ const HeaderItem: React.FC<Props> = props => {
|
||||
itemCount={props.itemCount}
|
||||
expanded={props.item.expanded}
|
||||
onContextMenu={onContextMenu}
|
||||
depth={0}
|
||||
depth={item.depth}
|
||||
highlightOnHover={false}
|
||||
className='sidebar-header-container'
|
||||
{...item.extraProps}
|
||||
|
@@ -23,6 +23,7 @@ interface Props {
|
||||
draggable?: boolean;
|
||||
'data-folder-id'?: string;
|
||||
'data-id'?: string;
|
||||
'data-tag-id'?: string;
|
||||
'data-type'?: ModelType;
|
||||
}
|
||||
|
||||
@@ -40,8 +41,7 @@ const ListItemWrapper: React.FC<Props> = props => {
|
||||
aria-setsize={props.itemCount}
|
||||
aria-selected={props.selected}
|
||||
aria-expanded={props.expanded}
|
||||
// aria-level is 1-based, where depth is zero-based
|
||||
aria-level={props.depth + 1}
|
||||
aria-level={props.depth}
|
||||
tabIndex={props.selected ? 0 : -1}
|
||||
|
||||
onContextMenu={props.onContextMenu}
|
||||
@@ -56,6 +56,7 @@ const ListItemWrapper: React.FC<Props> = props => {
|
||||
style={style}
|
||||
data-folder-id={props['data-folder-id']}
|
||||
data-id={props['data-id']}
|
||||
data-tag-id={props['data-tag-id']}
|
||||
data-type={props['data-type']}
|
||||
>
|
||||
{props.children}
|
||||
|
@@ -5,4 +5,4 @@
|
||||
@use 'styles/sidebar-expand-link.scss';
|
||||
@use 'styles/sidebar-header-container.scss';
|
||||
@use 'styles/sidebar-spacer-item.scss';
|
||||
@use 'styles/new-folder-button.scss';
|
||||
@use 'styles/sidebar-header-button.scss';
|
@@ -5,7 +5,10 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: calc(var(--joplin-main-padding) + (var(--depth) * 16px) - 16px);
|
||||
// The top-level folder has depth 2, so we need to subtract for the item
|
||||
// to have the correct padding
|
||||
--absolute-depth: calc(var(--depth) - 2);
|
||||
padding-left: calc(var(--joplin-main-padding) + (var(--absolute-depth) * 16px));
|
||||
background: none;
|
||||
transition: 0.1s;
|
||||
|
||||
|
@@ -1,11 +1,11 @@
|
||||
|
||||
.new-folder-button {
|
||||
.sidebar-header-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-end: 0;
|
||||
|
||||
padding-inline-end: 15px;
|
||||
padding-top: 4px;
|
||||
padding-top: 8px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
|
||||
@@ -22,4 +22,8 @@
|
||||
color: var(--joplin-color-active2);
|
||||
background: none;
|
||||
}
|
||||
|
||||
&.-collapseall {
|
||||
right: 25px;
|
||||
}
|
||||
}
|
@@ -16,9 +16,15 @@ export enum ListItemType {
|
||||
|
||||
interface BaseListItem {
|
||||
key: string;
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
}
|
||||
|
||||
export interface HeaderListItem extends BaseListItem {
|
||||
interface ToplevelListItem extends BaseListItem {
|
||||
depth: 1;
|
||||
}
|
||||
|
||||
export interface HeaderListItem extends ToplevelListItem {
|
||||
kind: ListItemType.Header;
|
||||
label: string;
|
||||
expanded: boolean;
|
||||
@@ -42,10 +48,9 @@ export interface FolderListItem extends BaseListItem {
|
||||
kind: ListItemType.Folder;
|
||||
folder: FolderEntity;
|
||||
hasChildren: boolean;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface SpacerListItem extends BaseListItem {
|
||||
export interface SpacerListItem extends ToplevelListItem {
|
||||
kind: ListItemType.Spacer;
|
||||
}
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { GotoAnythingUserData, Mode, UserDataCallbackReject, UserDataCallbackResolve } from '../../../plugins/GotoAnything';
|
||||
const PluginManager = require('@joplin/lib/services/PluginManager');
|
||||
|
||||
export enum UiType {
|
||||
@@ -8,6 +9,10 @@ export enum UiType {
|
||||
ControlledApi = 'controlledApi',
|
||||
}
|
||||
|
||||
export interface GotoAnythingOptions {
|
||||
mode?: Mode;
|
||||
}
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'gotoAnything',
|
||||
label: () => _('Goto Anything...'),
|
||||
@@ -24,19 +29,26 @@ function menuItemById(id: string) {
|
||||
// calling the click() handler.
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext, uiType: UiType = UiType.GotoAnything) => {
|
||||
execute: async (_context: CommandContext, uiType: UiType = UiType.GotoAnything, options: GotoAnythingOptions = null) => {
|
||||
options = {
|
||||
mode: Mode.Default,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (uiType === UiType.GotoAnything) {
|
||||
menuItemById('gotoAnything').click();
|
||||
} else if (uiType === UiType.CommandPalette) {
|
||||
menuItemById('commandPalette').click();
|
||||
} else if (uiType === UiType.ControlledApi) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
return new Promise((resolve: Function, reject: Function) => {
|
||||
return new Promise((resolve: UserDataCallbackResolve, reject: UserDataCallbackReject) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const menuItem = PluginManager.instance().menuItems().find((i: any) => i.id === 'controlledApi');
|
||||
menuItem.userData = {
|
||||
const userData: GotoAnythingUserData = {
|
||||
callback: { resolve, reject },
|
||||
mode: options.mode,
|
||||
};
|
||||
menuItem.userData = userData;
|
||||
menuItem.click();
|
||||
});
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ import * as exportPdf from './exportPdf';
|
||||
import * as gotoAnything from './gotoAnything';
|
||||
import * as hideModalMessage from './hideModalMessage';
|
||||
import * as leaveSharedFolder from './leaveSharedFolder';
|
||||
import * as linkToNote from './linkToNote';
|
||||
import * as moveToFolder from './moveToFolder';
|
||||
import * as newFolder from './newFolder';
|
||||
import * as newNote from './newNote';
|
||||
@@ -28,7 +29,6 @@ import * as restoreNote from './restoreNote';
|
||||
import * as revealResourceFile from './revealResourceFile';
|
||||
import * as search from './search';
|
||||
import * as setTags from './setTags';
|
||||
import * as showEditorPlugin from './showEditorPlugin';
|
||||
import * as showModalMessage from './showModalMessage';
|
||||
import * as showNoteContentProperties from './showNoteContentProperties';
|
||||
import * as showNoteProperties from './showNoteProperties';
|
||||
@@ -36,7 +36,6 @@ import * as showPrompt from './showPrompt';
|
||||
import * as showShareFolderDialog from './showShareFolderDialog';
|
||||
import * as showShareNoteDialog from './showShareNoteDialog';
|
||||
import * as showSpellCheckerMenu from './showSpellCheckerMenu';
|
||||
import * as toggleEditorPlugin from './toggleEditorPlugin';
|
||||
import * as toggleEditors from './toggleEditors';
|
||||
import * as toggleLayoutMoveMode from './toggleLayoutMoveMode';
|
||||
import * as toggleMenuBar from './toggleMenuBar';
|
||||
@@ -58,6 +57,7 @@ const index: any[] = [
|
||||
gotoAnything,
|
||||
hideModalMessage,
|
||||
leaveSharedFolder,
|
||||
linkToNote,
|
||||
moveToFolder,
|
||||
newFolder,
|
||||
newNote,
|
||||
@@ -78,7 +78,6 @@ const index: any[] = [
|
||||
revealResourceFile,
|
||||
search,
|
||||
setTags,
|
||||
showEditorPlugin,
|
||||
showModalMessage,
|
||||
showNoteContentProperties,
|
||||
showNoteProperties,
|
||||
@@ -86,7 +85,6 @@ const index: any[] = [
|
||||
showShareFolderDialog,
|
||||
showShareNoteDialog,
|
||||
showSpellCheckerMenu,
|
||||
toggleEditorPlugin,
|
||||
toggleEditors,
|
||||
toggleLayoutMoveMode,
|
||||
toggleMenuBar,
|
||||
|
@@ -0,0 +1,37 @@
|
||||
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Mode } from '../../../plugins/GotoAnything';
|
||||
import { GotoAnythingOptions, UiType } from './gotoAnything';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import markdownUtils from '@joplin/lib/markdownUtils';
|
||||
|
||||
const logger = Logger.create('linkToNote');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'linkToNote',
|
||||
label: () => _('Link to note...'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
const options: GotoAnythingOptions = {
|
||||
mode: Mode.TitleOnly,
|
||||
};
|
||||
const result = await CommandService.instance().execute('gotoAnything', UiType.ControlledApi, options);
|
||||
if (!result) return result;
|
||||
|
||||
if (result.type !== ModelType.Note) {
|
||||
logger.warn('Retrieved item is not a note:', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
const link = `[${markdownUtils.escapeTitleText(result.item.title)}](:/${markdownUtils.escapeLinkUrl(result.item.id)})`;
|
||||
await CommandService.instance().execute('insertText', link);
|
||||
return result;
|
||||
},
|
||||
|
||||
enabledCondition: 'markdownEditorPaneVisible || richTextEditorVisible',
|
||||
};
|
||||
};
|
@@ -33,6 +33,10 @@ export const SearchInput = styled(StyledInput)`
|
||||
padding-right: 20px;
|
||||
flex: 1;
|
||||
width: 10px;
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
|
@@ -4,6 +4,7 @@ export default function() {
|
||||
'copyDevCommand',
|
||||
'exportPdf',
|
||||
'focusElementNoteBody',
|
||||
'focusElementNoteViewer',
|
||||
'focusElementNoteList',
|
||||
'focusElementNoteTitle',
|
||||
'focusElementSideBar',
|
||||
@@ -43,9 +44,11 @@ export default function() {
|
||||
'togglePerFolderSortOrder',
|
||||
'toggleSideBar',
|
||||
'toggleVisiblePanes',
|
||||
'toggleEditorPlugin',
|
||||
'toggleTabMovesFocus',
|
||||
'editor.deleteLine',
|
||||
'editor.duplicateLine',
|
||||
'newAppInstance',
|
||||
// We cannot put the undo/redo commands in the menu because they are
|
||||
// editor-specific commands. If we put them there it will break the
|
||||
// undo/redo in regular text fields.
|
||||
@@ -59,6 +62,7 @@ export default function() {
|
||||
'editor.sortSelectedLines',
|
||||
'editor.swapLineUp',
|
||||
'editor.swapLineDown',
|
||||
'linkToNote',
|
||||
'exportDeletionLog',
|
||||
'toggleSafeMode',
|
||||
'showShareNoteDialog',
|
||||
|
@@ -377,6 +377,53 @@
|
||||
contentElement.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
const getLineCorrespondingTo = (editorLineNumber) => {
|
||||
const lineElements = document.getElementsByClassName('maps-to-line');
|
||||
let lastLineElement;
|
||||
let lastLine = 0;
|
||||
for (const element of lineElements) {
|
||||
// Stop just before the element that corresponds to a greater position
|
||||
if (Number(element.getAttribute('source-line')) > editorLineNumber) {
|
||||
break;
|
||||
}
|
||||
lastLineElement = element;
|
||||
}
|
||||
return lastLineElement;
|
||||
};
|
||||
|
||||
const makeTemporarilyFocusable = (element) => {
|
||||
const dataOriginalTabIndexAttr = 'data-original-tabindex';
|
||||
const originalTabIndex = (
|
||||
element.getAttribute(dataOriginalTabIndexAttr) ?? element.getAttribute('tabindex')
|
||||
);
|
||||
element.setAttribute(dataOriginalTabIndexAttr, originalTabIndex);
|
||||
element.setAttribute('tabindex', '0');
|
||||
|
||||
return {
|
||||
reset: () => {
|
||||
element.setAttribute('tabindex', originalTabIndex);
|
||||
element.removeAttribute(dataOriginalTabIndexAttr);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
ipc.focusLine = (event) => {
|
||||
const targetLine = event.line;
|
||||
const lineElement = getLineCorrespondingTo(targetLine);
|
||||
if (lineElement) {
|
||||
// To allow focusing, the element needs to briefly have tabindex=0.
|
||||
const { reset } = makeTemporarilyFocusable(lineElement);
|
||||
|
||||
lineElement.focus({ preventScroll: true });
|
||||
|
||||
// Reset the tabindex after the browser has had time to focus the element.
|
||||
// When a screen reader is enabled, focus stays on the lineElement.
|
||||
setTimeout(() => {
|
||||
reset();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
const rewriteFileUrls = (accessKey) => {
|
||||
if (!accessKey) return;
|
||||
|
||||
|
@@ -1,7 +1,9 @@
|
||||
# Integration tests
|
||||
|
||||
The integration tests in this directory can be run with `yarn playwright test`.
|
||||
The integration tests in this directory can be run with `yarn test-ui`.
|
||||
|
||||
- To run all tests from a specific file, use `yarn test-ui testFileName`. For example, `yarn test-ui wcag` to run the tests in `wcag.ts`.
|
||||
- To run all tests matching a pattern, use `yarn test-ui -g "pattern here"`, where `-g` is short for "grep".
|
||||
- Tests use a `test-profile` directory that should be re-created before every test.
|
||||
- Only one Electron application should be instantiated per test file.
|
||||
- Files in the `models/` directory follow [the page object model](https://playwright.dev/docs/pom).
|
||||
@@ -15,3 +17,11 @@ with Playwright:
|
||||
- [The Playwright ElectronApp docs](https://playwright.dev/docs/api/class-electronapplication)
|
||||
- [Electron Playwright example repository](https://github.com/spaceagetv/electron-playwright-example)
|
||||
- [Playwright best practices](https://playwright.dev/docs/best-practices)
|
||||
|
||||
# FAQ
|
||||
|
||||
## How do I fix timeout-related test failures?
|
||||
|
||||
If Playwright tests are timing out, consider modifying `playwright.config.ts` in the `app-desktop` folder. For example, increase the `timeout` option to `120_000` (2 minutes).
|
||||
|
||||
Alternatively, try temporarily disabling `fullyParallel` (which disables running tests in parallel).
|
||||
|
@@ -116,7 +116,10 @@ test.describe('main', () => {
|
||||
await editor.attachFileButton.click();
|
||||
|
||||
const viewerFrame = editor.getNoteViewerFrameLocator();
|
||||
const renderedImage = viewerFrame.getByAltText(filename);
|
||||
const renderedImage = viewerFrame
|
||||
.getByAltText(filename)
|
||||
// Work around occasional "resolved to 2 elements" errors in CI
|
||||
.last();
|
||||
|
||||
const fullSize = await getImageSourceSize(renderedImage);
|
||||
|
||||
|
@@ -230,5 +230,28 @@ test.describe('markdownEditor', () => {
|
||||
// Editor should be focused
|
||||
await expect(focusInMarkdownEditor).toBeAttached();
|
||||
});
|
||||
|
||||
test('focusElementNoteViewer should move focus to the viewer', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.waitFor();
|
||||
const noteEditor = mainScreen.noteEditor;
|
||||
|
||||
await mainScreen.createNewNote('Note');
|
||||
|
||||
await noteEditor.focusCodeMirrorEditor();
|
||||
await mainWindow.keyboard.type('# Test');
|
||||
await mainWindow.keyboard.press('Enter');
|
||||
await mainWindow.keyboard.press('Enter');
|
||||
await mainWindow.keyboard.type('Test paragraph.');
|
||||
|
||||
// Wait for rendering
|
||||
await expect(noteEditor.getNoteViewerFrameLocator().getByText('Test paragraph.')).toBeAttached();
|
||||
|
||||
// Move focus
|
||||
await mainScreen.goToAnything.runCommand(electronApp, 'focusElementNoteViewer');
|
||||
|
||||
// Note viewer should be focused
|
||||
await expect(noteEditor.noteViewerContainer).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -44,6 +44,48 @@ test.describe('sidebar', () => {
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
|
||||
});
|
||||
|
||||
test('left/right arrow keys should expand/collapse notebooks', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
// Build the folder hierarchy
|
||||
const folderAHeader = await sidebar.createNewFolder('Folder A');
|
||||
await expect(folderAHeader).toBeVisible();
|
||||
const folderBHeader = await sidebar.createNewFolder('Folder B');
|
||||
const folderCHeader = await sidebar.createNewFolder('Folder C');
|
||||
const folderDHeader = await sidebar.createNewFolder('Folder D');
|
||||
await folderBHeader.dragTo(folderAHeader);
|
||||
await folderCHeader.dragTo(folderAHeader);
|
||||
await folderDHeader.dragTo(folderCHeader);
|
||||
|
||||
// Folders should have correct initial levels
|
||||
await expect(folderAHeader).toHaveJSProperty('ariaLevel', '2');
|
||||
await expect(folderBHeader).toHaveJSProperty('ariaLevel', '3');
|
||||
await expect(folderCHeader).toHaveJSProperty('ariaLevel', '3');
|
||||
await expect(folderDHeader).toHaveJSProperty('ariaLevel', '4');
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
await folderBHeader.click();
|
||||
|
||||
// Pressing [left] on a folder with no children should jump to its parent
|
||||
await mainWindow.keyboard.press('ArrowLeft');
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('Folder A');
|
||||
|
||||
// Pressing [left] again should collapse the folder
|
||||
await expect(folderAHeader).toHaveJSProperty('ariaExpanded', 'true');
|
||||
await mainWindow.keyboard.press('ArrowLeft');
|
||||
await expect(folderAHeader).toHaveJSProperty('ariaExpanded', 'false');
|
||||
// Should still be focused
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('Folder A');
|
||||
|
||||
// Pressing [right] on a collapsed folder should expand it
|
||||
await mainWindow.keyboard.press('ArrowRight');
|
||||
await expect(folderAHeader).toHaveJSProperty('ariaExpanded', 'true');
|
||||
// Pressing [right] again should move to the next item
|
||||
await mainWindow.keyboard.press('ArrowRight');
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('Folder B');
|
||||
});
|
||||
|
||||
test('should allow changing the parent of a folder by drag-and-drop', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
@@ -25,7 +25,7 @@ const getAndResizeMainWindow = async (electronApp: ElectronApplication) => {
|
||||
|
||||
// Setting the viewport size helps keep test environments consistent.
|
||||
await mainWindow.setViewportSize({
|
||||
width: 1200,
|
||||
width: 1300,
|
||||
height: 800,
|
||||
});
|
||||
|
||||
|
@@ -25,28 +25,27 @@ process.on('unhandledRejection', (reason, p) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Likewise, we want to know if a profile is specified early, in particular
|
||||
// to save the window state data.
|
||||
function getProfileFromArgs(args) {
|
||||
const getFlagValueFromArgs = (args, flag, defaultValue) => {
|
||||
if (!args) return null;
|
||||
const profileIndex = args.indexOf('--profile');
|
||||
if (profileIndex <= 0 || profileIndex >= args.length - 1) return null;
|
||||
const profileValue = args[profileIndex + 1];
|
||||
return profileValue ? profileValue : null;
|
||||
}
|
||||
const index = args.indexOf(flag);
|
||||
if (index <= 0 || index >= args.length - 1) return defaultValue;
|
||||
const value = args[index + 1];
|
||||
return value ? value : defaultValue;
|
||||
};
|
||||
|
||||
Logger.fsDriver_ = new FsDriverNode();
|
||||
|
||||
const env = envFromArgs(process.argv);
|
||||
const profileFromArgs = getProfileFromArgs(process.argv);
|
||||
const profileFromArgs = getFlagValueFromArgs(process.argv, '--profile', null);
|
||||
const isDebugMode = !!process.argv && process.argv.indexOf('--debug') >= 0;
|
||||
const altInstanceId = getFlagValueFromArgs(process.argv, '--alt-instance-id', '');
|
||||
|
||||
// We initialize all these variables here because they are needed from the main process. They are
|
||||
// then passed to the renderer process via the bridge.
|
||||
const appId = `net.cozic.joplin${env === 'dev' ? 'dev' : ''}-desktop`;
|
||||
let appName = env === 'dev' ? 'joplindev' : 'joplin';
|
||||
if (appId.indexOf('-desktop') >= 0) appName += '-desktop';
|
||||
const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName);
|
||||
const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName, altInstanceId);
|
||||
const settingsPath = `${rootProfileDir}/settings.json`;
|
||||
let autoUploadCrashDumps = false;
|
||||
|
||||
@@ -67,7 +66,7 @@ const initialCallbackUrl = process.argv.find((arg) => isCallbackUrl(arg));
|
||||
|
||||
const wrapper = new ElectronAppWrapper(electronApp, env, rootProfileDir, isDebugMode, initialCallbackUrl);
|
||||
|
||||
initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps);
|
||||
initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId);
|
||||
|
||||
wrapper.start().catch((error) => {
|
||||
console.error('Electron App fatal error:');
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.3",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
@@ -137,14 +137,14 @@
|
||||
"@playwright/test": "1.45.3",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "18.19.64",
|
||||
"@types/node": "18.19.67",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"@types/tesseract.js": "2.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"electron": "34.0.0",
|
||||
"electron": "35.0.1",
|
||||
"electron-builder": "24.13.3",
|
||||
"glob": "10.4.5",
|
||||
"gulp": "4.0.2",
|
||||
|
@@ -24,7 +24,7 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? 'line' : 'html',
|
||||
|
||||
// The CI machines can sometimes be very slow. Increase per-test timeout in CI.
|
||||
timeout: process.env.CI ? 50_000 : 30_000, // milliseconds
|
||||
timeout: process.env.CI ? 70_000 : 60_000, // milliseconds
|
||||
|
||||
// Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions.
|
||||
use: {
|
||||
|
@@ -23,7 +23,6 @@ import Resource from '@joplin/lib/models/Resource';
|
||||
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import Dialog from '../gui/Dialog';
|
||||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||
import { htmlentities } from '@joplin/utils/html';
|
||||
|
||||
const logger = Logger.create('GotoAnything');
|
||||
|
||||
@@ -41,6 +40,39 @@ interface GotoAnythingSearchResult {
|
||||
item_type?: ModelType;
|
||||
}
|
||||
|
||||
// GotoAnything supports several modes:
|
||||
//
|
||||
// - Default: Search in note title, body. Can search for folders, tags, etc. This is the full
|
||||
// featured GotoAnything.
|
||||
//
|
||||
// - TitleOnly: Search in note titles only.
|
||||
//
|
||||
// These different modes can be set from the `gotoAnything` command.
|
||||
|
||||
export enum Mode {
|
||||
Default = 0,
|
||||
TitleOnly,
|
||||
}
|
||||
|
||||
export interface UserDataCallbackEvent {
|
||||
type: ModelType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
item: any;
|
||||
}
|
||||
|
||||
export type UserDataCallbackResolve = (event: UserDataCallbackEvent)=> void;
|
||||
export type UserDataCallbackReject = (error: Error)=> void;
|
||||
export interface UserDataCallback {
|
||||
resolve: UserDataCallbackResolve;
|
||||
reject: UserDataCallbackReject;
|
||||
}
|
||||
|
||||
export interface GotoAnythingUserData {
|
||||
startString?: string;
|
||||
mode?: Mode;
|
||||
callback?: UserDataCallback;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
@@ -48,8 +80,7 @@ interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
folders: any[];
|
||||
showCompletedTodos: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
userData: any;
|
||||
userData: GotoAnythingUserData;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -132,8 +163,8 @@ class DialogComponent extends React.PureComponent<Props, State> {
|
||||
private itemListRef: any;
|
||||
private listUpdateQueue_: AsyncActionQueue;
|
||||
private markupToHtml_: MarkupToHtml;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private userCallback_: any = null;
|
||||
private userCallback_: UserDataCallback|null = null;
|
||||
private mode_: Mode;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
@@ -143,6 +174,8 @@ class DialogComponent extends React.PureComponent<Props, State> {
|
||||
this.userCallback_ = props?.userData?.callback;
|
||||
this.listUpdateQueue_ = new AsyncActionQueue(100);
|
||||
|
||||
this.mode_ = props?.userData?.mode ? props.userData.mode : Mode.Default;
|
||||
|
||||
this.state = {
|
||||
query: startString,
|
||||
results: [],
|
||||
@@ -342,6 +375,13 @@ class DialogComponent extends React.PureComponent<Props, State> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
resultsInBody = !!results.find((row: any) => row.fields.includes('body'));
|
||||
|
||||
if (this.mode_ === Mode.TitleOnly) {
|
||||
resultsInBody = false;
|
||||
results = results.filter(r => {
|
||||
return r.fields.includes('title');
|
||||
});
|
||||
}
|
||||
|
||||
const resourceIds = results.filter(r => r.item_type === ModelType.Resource).map(r => r.item_id);
|
||||
const resources = await Resource.resourceOcrTextsByIds(resourceIds);
|
||||
|
||||
@@ -561,9 +601,7 @@ class DialogComponent extends React.PureComponent<Props, State> {
|
||||
);
|
||||
};
|
||||
|
||||
const titleHtml = item.fragments
|
||||
? `<span style="font-weight: bold; color: ${theme.color};">${htmlentities(item.title)}</span>`
|
||||
: wrapKeywordMatches(item.title);
|
||||
const titleHtml = wrapKeywordMatches(item.title);
|
||||
|
||||
const fragmentsHtml = !item.fragments ? null : wrapKeywordMatches(item.fragments);
|
||||
|
||||
@@ -587,8 +625,8 @@ class DialogComponent extends React.PureComponent<Props, State> {
|
||||
aria-posinset={index + 1}
|
||||
>
|
||||
<div style={style.rowTitle} dangerouslySetInnerHTML={{ __html: titleHtml }}></div>
|
||||
{fragmentComp}
|
||||
{pathComp}
|
||||
{this.mode_ === Mode.TitleOnly ? null : fragmentComp}
|
||||
{this.mode_ === Mode.TitleOnly ? null : pathComp}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -671,6 +709,14 @@ class DialogComponent extends React.PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
private helpText() {
|
||||
if (this.mode_ === Mode.TitleOnly) {
|
||||
return _('Type a note title to search for it.');
|
||||
} else {
|
||||
return _('Type a note title or part of its content to jump to it. Or type # followed by a tag name, or @ followed by a notebook name. Or type : to search for commands.');
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const style = this.style();
|
||||
const helpTextId = 'goto-anything-help-text';
|
||||
@@ -681,7 +727,7 @@ class DialogComponent extends React.PureComponent<Props, State> {
|
||||
id={helpTextId}
|
||||
style={style.help}
|
||||
hidden={!this.state.showHelp}
|
||||
>{_('Type a note title or part of its content to jump to it. Or type # followed by a tag name, or @ followed by a notebook name. Or type : to search for commands.')}</div>
|
||||
>{this.helpText()}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
|
@@ -180,7 +180,7 @@ fi
|
||||
|
||||
if [ "$IS_DESKTOP" = "1" ]; then
|
||||
cd "$ROOT_DIR/packages/app-desktop"
|
||||
yarn start --profile "$PROFILE_DIR"
|
||||
yarn start --profile "$PROFILE_DIR" --alt-instance-id $USER_NUM
|
||||
else
|
||||
cd "$ROOT_DIR/packages/app-cli"
|
||||
if [[ $CMD == "--" ]]; then
|
||||
|
@@ -12,6 +12,7 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
|
||||
const windowId = options?.windowId ?? defaultWindowId;
|
||||
const isMainWindow = windowId === defaultWindowId;
|
||||
const windowState = stateUtils.windowStateById(state, windowId);
|
||||
const isAltInstance = !!state.settings.altInstanceId;
|
||||
|
||||
return {
|
||||
...libStateToWhenClauseContext(state, options),
|
||||
@@ -26,6 +27,7 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
|
||||
gotoAnythingVisible: !!state.visibleDialogs['gotoAnything'],
|
||||
sidebarVisible: isMainWindow && !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
||||
noteListHasNotes: !!windowState.notes.length,
|
||||
isAltInstance,
|
||||
|
||||
// Deprecated
|
||||
sideBarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
||||
|
@@ -2,9 +2,9 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import bridge from './bridge';
|
||||
|
||||
|
||||
export default async (linuxSafeRestart = true) => {
|
||||
export default async () => {
|
||||
Setting.setValue('wasClosedSuccessfully', true);
|
||||
await Setting.saveAll();
|
||||
|
||||
bridge().restart(linuxSafeRestart);
|
||||
await bridge().restart();
|
||||
};
|
||||
|
@@ -6,7 +6,7 @@
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
TEMP_PATH=~/src/plugin-tests
|
||||
NEED_COMPILING=1
|
||||
PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/toast
|
||||
PLUGIN_PATH=~/src/plugin-yesyoucan
|
||||
|
||||
if [[ $NEED_COMPILING == 1 ]]; then
|
||||
mkdir -p "$TEMP_PATH"
|
||||
|
@@ -25,7 +25,7 @@ async function main() {
|
||||
// wrong one. However it means it will have to be manually upgraded for each
|
||||
// new Electron release. Some ABI map there:
|
||||
// https://github.com/electron/node-abi/tree/master/test
|
||||
const forceAbiArgs = '--force-abi 132';
|
||||
const forceAbiArgs = '--force-abi 134';
|
||||
|
||||
if (isWindows()) {
|
||||
// Cannot run this in parallel, or the 64-bit version might end up
|
||||
|
@@ -21,14 +21,14 @@ const restartInSafeModeFromMain = async () => {
|
||||
shimInit({});
|
||||
|
||||
const startFlags = await processStartFlags(bridge().processArgv());
|
||||
const { rootProfileDir } = determineBaseAppDirs(startFlags.matched.profileDir, appName);
|
||||
const { rootProfileDir } = determineBaseAppDirs(startFlags.matched.profileDir, appName, Setting.value('altInstanceId'));
|
||||
const { profileDir } = await initProfile(rootProfileDir);
|
||||
|
||||
// We can't access the database, so write to a file instead.
|
||||
const safeModeFlagFile = join(profileDir, safeModeFlagFilename);
|
||||
await writeFile(safeModeFlagFile, 'true', 'utf8');
|
||||
|
||||
bridge().restart();
|
||||
await bridge().restart();
|
||||
};
|
||||
|
||||
export default restartInSafeModeFromMain;
|
||||
|
@@ -70,6 +70,13 @@ def enableProguardInReleaseBuilds = false
|
||||
def jscFlavor = 'org.webkit:android-jsc:+'
|
||||
|
||||
android {
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path file('src/main/cpp/CMakeLists.txt')
|
||||
version '3.22.1'
|
||||
}
|
||||
}
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
@@ -79,14 +86,19 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097763
|
||||
versionName "3.3.0"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
versionCode 2097766
|
||||
versionName "3.3.3"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
// Needed to fix: The number of method references in a .dex file cannot exceed 64K
|
||||
multiDexEnabled true
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags '-DCMAKE_BUILD_TYPE=Release'
|
||||
}
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
@@ -95,14 +107,14 @@ android {
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
release {
|
||||
if (project.hasProperty('JOPLIN_RELEASE_STORE_FILE')) {
|
||||
storeFile file(JOPLIN_RELEASE_STORE_FILE)
|
||||
storePassword JOPLIN_RELEASE_STORE_PASSWORD
|
||||
keyAlias JOPLIN_RELEASE_KEY_ALIAS
|
||||
keyPassword JOPLIN_RELEASE_KEY_PASSWORD
|
||||
}
|
||||
}
|
||||
release {
|
||||
if (project.hasProperty('JOPLIN_RELEASE_STORE_FILE')) {
|
||||
storeFile file(JOPLIN_RELEASE_STORE_FILE)
|
||||
storePassword JOPLIN_RELEASE_STORE_PASSWORD
|
||||
keyAlias JOPLIN_RELEASE_KEY_ALIAS
|
||||
keyPassword JOPLIN_RELEASE_KEY_PASSWORD
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
@@ -127,10 +139,6 @@ dependencies {
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
|
||||
// Needed for Whisper speech-to-text
|
||||
implementation 'com.microsoft.onnxruntime:onnxruntime-android:latest.release'
|
||||
implementation 'com.microsoft.onnxruntime:onnxruntime-extensions-android:latest.release'
|
||||
}
|
||||
|
||||
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
|
||||
|
64
packages/app-mobile/android/app/src/main/cpp/CMakeLists.txt
Normal file
64
packages/app-mobile/android/app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,64 @@
|
||||
|
||||
# For more information about using CMake with Android Studio, read the
|
||||
# documentation: https://d.android.com/studio/projects/add-native-code.html.
|
||||
# For more examples on how to use CMake, see https://github.com/android/ndk-samples.
|
||||
|
||||
# Sets the minimum CMake version required for this project.
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
|
||||
# Declares the project name. The project name can be accessed via ${ PROJECT_NAME},
|
||||
# Since this is the top level CMakeLists.txt, the project name is also accessible
|
||||
# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level
|
||||
# build script scope).
|
||||
project("joplin")
|
||||
|
||||
# Creates and names a library, sets it as either STATIC
|
||||
# or SHARED, and provides the relative paths to its source code.
|
||||
# You can define multiple libraries, and CMake builds them for you.
|
||||
# Gradle automatically packages shared libraries with your APK.
|
||||
#
|
||||
# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define
|
||||
# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME}
|
||||
# is preferred for the same purpose.
|
||||
#
|
||||
# In order to load a library into your app from Java/Kotlin, you must call
|
||||
# System.loadLibrary() and pass the name of the library defined here;
|
||||
# for GameActivity/NativeActivity derived applications, the same library name must be
|
||||
# used in the AndroidManifest.xml file.
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
# List C/C++ source files with relative paths to this CMakeLists.txt.
|
||||
whisperWrapper.cpp
|
||||
utils/WhisperSession.cpp
|
||||
utils/findLongestSilence.cpp
|
||||
utils/findLongestSilence_test.cpp
|
||||
)
|
||||
|
||||
|
||||
|
||||
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")
|
||||
|
||||
# Whisper: See https://stackoverflow.com/a/76290722
|
||||
add_subdirectory(${WHISPER_LIB_DIR} ./whisper)
|
||||
|
||||
# Directories for header files
|
||||
target_include_directories(
|
||||
${CMAKE_PROJECT_NAME}
|
||||
PUBLIC
|
||||
${PROJECT_BASE_DIR}/shared
|
||||
${WHISPER_LIB_DIR}/include
|
||||
)
|
||||
|
||||
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
# can link libraries from various origins, such as libraries defined in this
|
||||
# build script, prebuilt third-party libraries, or Android system libraries.
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
whisper
|
||||
# List libraries link to the target library
|
||||
android
|
||||
log
|
||||
)
|
@@ -0,0 +1,154 @@
|
||||
#include "WhisperSession.h"
|
||||
|
||||
#include <utility>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include "whisper.h"
|
||||
#include "findLongestSilence.h"
|
||||
#include "androidUtil.h"
|
||||
|
||||
WhisperSession::WhisperSession(const std::string& modelPath, std::string lang, std::string prompt)
|
||||
: lang_ {std::move(lang)}, prompt_ {std::move(prompt)} {
|
||||
whisper_context_params contextParams = whisper_context_default_params();
|
||||
|
||||
// Lifetime(pModelPath): Whisper.cpp creates a copy of pModelPath and stores it in a std::string.
|
||||
// whisper_init_from_file_with_params doesn't seem to otherwise save pModelPath. As such, it's
|
||||
// safe to pass a pointer to a std::string's representation:
|
||||
const char *pModelPath = modelPath.c_str();
|
||||
pContext_ = whisper_init_from_file_with_params(pModelPath, contextParams);
|
||||
|
||||
if (pContext_ == nullptr) {
|
||||
throw std::runtime_error("Unable to initialize the Whisper context.");
|
||||
}
|
||||
}
|
||||
|
||||
WhisperSession::~WhisperSession() {
|
||||
if (pContext_ != nullptr) {
|
||||
whisper_free(pContext_);
|
||||
}
|
||||
}
|
||||
|
||||
whisper_full_params
|
||||
WhisperSession::buildWhisperParams_() {
|
||||
whisper_full_params params = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
|
||||
// WHISPER_SAMPLING_BEAM_SEARCH is an alternative to greedy:
|
||||
// params.beam_search = { .beam_size = 2 };
|
||||
params.print_realtime = false;
|
||||
// Disable timestamps: They make creating custom Whisper models more difficult:
|
||||
params.print_timestamps = false;
|
||||
params.no_timestamps = true;
|
||||
|
||||
params.print_progress = false;
|
||||
params.translate = false;
|
||||
params.offset_ms = 0;
|
||||
params.single_segment = true;
|
||||
// Avoid non-speech tokens (e.g. "(crackle)"). For now, this is disabled because it seems to
|
||||
// cause increased hallucinations (e.g. repeated "Thank you"s).
|
||||
// params.suppress_nst = true;
|
||||
params.temperature = 0; // Initial randomness
|
||||
// There's also a temperature_inc variable, which is used when decoding fails (Whisper increases
|
||||
// the temperature by temperature_inc and retries).
|
||||
|
||||
// Following the whisper streaming example in setting prompt_tokens to nullptr
|
||||
// when using VAD (Voice Activity Detection)
|
||||
params.initial_prompt = prompt_.c_str();
|
||||
params.prompt_tokens = nullptr;
|
||||
params.prompt_n_tokens = 0;
|
||||
|
||||
// Lifetime: lifetime(params) < lifetime(lang_) = lifetime(this).
|
||||
params.language = lang_.c_str();
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
std::string
|
||||
WhisperSession::transcribe_(const std::vector<float>& audio, size_t transcribeCount) {
|
||||
int minTranscribeLength = WHISPER_SAMPLE_RATE / 2; // 0.5s
|
||||
if (transcribeCount < minTranscribeLength) {
|
||||
return "";
|
||||
}
|
||||
|
||||
whisper_full_params params = buildWhisperParams_();
|
||||
whisper_reset_timings(pContext_);
|
||||
|
||||
transcribeCount = std::min(audio.size(), transcribeCount);
|
||||
|
||||
if (whisper_full(pContext_, params, audio.data(), transcribeCount) != 0) {
|
||||
throw std::runtime_error("Failed to run Whisper (non-zero exit status).");
|
||||
} else {
|
||||
whisper_print_timings(pContext_);
|
||||
}
|
||||
|
||||
// Tokens to be used as a prompt for the next run of Whisper
|
||||
unsigned int segmentCount = whisper_full_n_segments(pContext_);
|
||||
|
||||
// Build the results
|
||||
std::stringstream results;
|
||||
for (int i = 0; i < segmentCount; i++) {
|
||||
results << " " << whisper_full_get_segment_text(pContext_, i);
|
||||
}
|
||||
|
||||
std::string result = results.str();
|
||||
LOGD("Transcribed: %s (audio len %.2f)", result.c_str(), audio.size() / (float) WHISPER_SAMPLE_RATE);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string
|
||||
WhisperSession::splitAndTranscribeBefore_(int transcribeUpTo, int trimTo) {
|
||||
std::string result = transcribe_(audioBuffer_, transcribeUpTo);
|
||||
|
||||
// Trim
|
||||
LOGI("Trim to %.2f s, transcribe to %.2f s", (float) trimTo / WHISPER_SAMPLE_RATE, (float) transcribeUpTo / WHISPER_SAMPLE_RATE);
|
||||
audioBuffer_ = std::vector(audioBuffer_.begin() + trimTo, audioBuffer_.end());
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string
|
||||
WhisperSession::transcribeNextChunk(const float *pAudio, int sizeAudio) {
|
||||
std::string finalizedContent;
|
||||
|
||||
// Update the local audio buffer
|
||||
for (int i = 0; i < sizeAudio; i++) {
|
||||
audioBuffer_.push_back(pAudio[i]);
|
||||
}
|
||||
|
||||
// Does the audio buffer need to be split somewhere?
|
||||
int maximumSamples = WHISPER_SAMPLE_RATE * 25;
|
||||
if (audioBuffer_.size() >= maximumSamples) {
|
||||
float minSilenceSeconds = 0.3f;
|
||||
auto silenceRange = findLongestSilence(
|
||||
audioBuffer_, WHISPER_SAMPLE_RATE, minSilenceSeconds, maximumSamples
|
||||
);
|
||||
|
||||
// In this case, the audio is long enough that it needs to be split somewhere. If there's
|
||||
// no suitable pause available, default to splitting in the middle.
|
||||
int halfBufferSize = audioBuffer_.size() / 2;
|
||||
int transcribeTo = silenceRange.isValid ? silenceRange.start : halfBufferSize;
|
||||
int trimTo = silenceRange.isValid ? silenceRange.end : halfBufferSize;
|
||||
|
||||
finalizedContent = splitAndTranscribeBefore_(transcribeTo, trimTo);
|
||||
} else if (audioBuffer_.size() > WHISPER_SAMPLE_RATE * 3) {
|
||||
// Allow brief pauses to create new paragraphs:
|
||||
float minSilenceSeconds = 2.0f;
|
||||
auto splitPoint = findLongestSilence(
|
||||
audioBuffer_, WHISPER_SAMPLE_RATE, minSilenceSeconds, maximumSamples
|
||||
);
|
||||
if (splitPoint.isValid) {
|
||||
int tolerance = WHISPER_SAMPLE_RATE / 20; // 0.05s
|
||||
bool isCompletelySilent = splitPoint.start < tolerance && splitPoint.end > audioBuffer_.size() - tolerance;
|
||||
if (isCompletelySilent) {
|
||||
audioBuffer_.clear();
|
||||
} else {
|
||||
finalizedContent = splitAndTranscribeBefore_(splitPoint.start, splitPoint.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previewText_ = transcribe_(audioBuffer_, audioBuffer_.size());
|
||||
return finalizedContent;
|
||||
}
|
||||
|
||||
std::string WhisperSession::getPreview() {
|
||||
return previewText_;
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include "whisper.h"
|
||||
|
||||
class WhisperSession {
|
||||
public:
|
||||
WhisperSession(const std::string& modelPath, std::string lang, std::string prompt);
|
||||
~WhisperSession();
|
||||
std::string transcribeNextChunk(const float *pAudio, int sizeAudio);
|
||||
std::string getPreview();
|
||||
|
||||
private:
|
||||
// Current preview state
|
||||
std::string previewText_;
|
||||
|
||||
whisper_full_params buildWhisperParams_();
|
||||
std::string transcribe_(const std::vector<float>& audio, size_t samplesToTranscribe);
|
||||
std::string splitAndTranscribeBefore_(int transcribeUpTo, int trimTo);
|
||||
|
||||
whisper_context *pContext_;
|
||||
const std::string lang_;
|
||||
const std::string prompt_;
|
||||
|
||||
std::vector<float> audioBuffer_;
|
||||
};
|
||||
|
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <android/log.h>
|
||||
|
||||
// Use macros for these rather than functions. Functions generate a "may be unsafe"
|
||||
// warning because the compiler can't check that the first argument is a string
|
||||
// literal.
|
||||
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, "Whisper::JNI", __VA_ARGS__);
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, "Whisper::JNI", __VA_ARGS__);
|
||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "Whisper::JNI", __VA_ARGS__);
|
@@ -0,0 +1,111 @@
|
||||
#include "findLongestSilence.h"
|
||||
#include "androidUtil.h"
|
||||
|
||||
static void highpass(std::vector<float>& data, int sampleRate) {
|
||||
// Highpass filter. See https://en.wikipedia.org/wiki/High-pass_filter and
|
||||
// the example in whisper.cpp/streaming.
|
||||
float highpassCutoffHz = 60.0f;
|
||||
float RC = 1.0f / (2 * 3.1416f * highpassCutoffHz);
|
||||
float timePerSample = 1.0f / sampleRate;
|
||||
float alpha = RC / (RC + timePerSample);
|
||||
|
||||
float lastInput = data[0];
|
||||
for (int i = 1; i < data.size(); i++) {
|
||||
float currentInput = data[i];
|
||||
data[i] = alpha * data[i - 1] + alpha * (currentInput - lastInput);
|
||||
lastInput = currentInput;
|
||||
}
|
||||
}
|
||||
|
||||
SilenceRange findLongestSilence(
|
||||
const std::vector<float>& audioData,
|
||||
int sampleRate,
|
||||
float minSilenceLengthSeconds,
|
||||
int maxSilencePosition
|
||||
) {
|
||||
int bestCandidateLength = 0;
|
||||
int bestCandidateStart = -1;
|
||||
int bestCandidateEnd = -1;
|
||||
|
||||
int currentCandidateStart = -1;
|
||||
|
||||
std::vector<float> processedAudio { audioData };
|
||||
highpass(processedAudio, sampleRate);
|
||||
|
||||
// Break into windows of size `windowSize`:
|
||||
int windowSize = 256;
|
||||
int windowsPerSecond = sampleRate / windowSize;
|
||||
int quietWindows = 0;
|
||||
|
||||
// Finishes the current candidate for longest silence
|
||||
auto finalizeCandidate = [&] (int currentOffset) {
|
||||
bool hasCandidate = currentCandidateStart >= 0;
|
||||
if (!hasCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
int currentCandidateLength = currentOffset - currentCandidateStart;
|
||||
if (currentCandidateLength > bestCandidateLength && currentCandidateStart <= maxSilencePosition) {
|
||||
bestCandidateLength = currentCandidateLength;
|
||||
bestCandidateStart = currentCandidateStart;
|
||||
bestCandidateEnd = currentOffset;
|
||||
LOGD("New best candidate with length %d", currentCandidateLength);
|
||||
}
|
||||
|
||||
currentCandidateStart = -1;
|
||||
};
|
||||
|
||||
int windowOffset;
|
||||
for (windowOffset = 0; windowOffset < processedAudio.size() && windowOffset <= maxSilencePosition; windowOffset += windowSize) {
|
||||
int rollingAverageSize = 24;
|
||||
float threshold = static_cast<float>(rollingAverageSize) / 80.0f;
|
||||
|
||||
// Count the number of samples that (when averaged with the nearby samples)
|
||||
// are below some threshold value.
|
||||
float absSum = 0;
|
||||
int silentSamples = 0;
|
||||
for (int i = windowOffset; i < windowOffset + windowSize && i < processedAudio.size(); i++) {
|
||||
absSum += abs(processedAudio[i]);
|
||||
|
||||
bool isSumComplete = i - rollingAverageSize >= windowOffset;
|
||||
if (isSumComplete) {
|
||||
absSum -= abs(processedAudio[i - rollingAverageSize]);
|
||||
|
||||
if (absSum < threshold) {
|
||||
silentSamples++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The window should be considered "quiet" if enough samples were below the threshold.
|
||||
// Don't require all of them to be to allow clicks and pops.
|
||||
if (silentSamples >= windowSize * 3 / 4) {
|
||||
quietWindows ++;
|
||||
} else {
|
||||
quietWindows = 0;
|
||||
}
|
||||
|
||||
int minQuietWindows = static_cast<int>(windowsPerSecond * minSilenceLengthSeconds);
|
||||
if (quietWindows >= minQuietWindows && currentCandidateStart == -1) {
|
||||
// Found a candidate. Start it.
|
||||
currentCandidateStart = windowOffset;
|
||||
} else if (quietWindows == 0) {
|
||||
// Ended a candidate. Is it better than the best?
|
||||
finalizeCandidate(windowOffset);
|
||||
}
|
||||
}
|
||||
|
||||
finalizeCandidate(windowOffset);
|
||||
|
||||
// Return the best candidate.
|
||||
if (bestCandidateLength == 0) {
|
||||
return { .isValid = false, .start = 0, .end = 0 };
|
||||
} else {
|
||||
return {
|
||||
.isValid=true,
|
||||
.start=bestCandidateStart,
|
||||
.end=bestCandidateEnd
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <optional>
|
||||
#include <tuple>
|
||||
|
||||
struct SilenceRange {
|
||||
bool isValid;
|
||||
int start;
|
||||
int end;
|
||||
};
|
||||
|
||||
SilenceRange findLongestSilence(
|
||||
const std::vector<float>& audioData,
|
||||
int sampleRate,
|
||||
|
||||
// Minimum length of silence in seconds
|
||||
float minSilenceLengthSeconds,
|
||||
|
||||
// Doesn't check for silence at a position greater than maximumSilenceStart
|
||||
int maximumSilenceStart
|
||||
);
|
||||
|
||||
|
@@ -0,0 +1,169 @@
|
||||
#include "findLongestSilence_test.h"
|
||||
#include "findLongestSilence.h"
|
||||
#include "androidUtil.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <sstream>
|
||||
#include <cmath>
|
||||
#include <random>
|
||||
|
||||
static void testTones();
|
||||
static void testToneWithPause();
|
||||
static void testSilence();
|
||||
static void testNoise();
|
||||
|
||||
static void fail(const std::string& message);
|
||||
|
||||
struct GeneratedAudio {
|
||||
std::vector<float> data;
|
||||
int sampleRate;
|
||||
int sampleCount;
|
||||
};
|
||||
|
||||
using AudioGenerator = std::function<const float(float)>;
|
||||
static GeneratedAudio makeAudio(const AudioGenerator& generator, int sampleRate, float duration);
|
||||
static void expectNoSilence(const GeneratedAudio& audio, const std::string& testLabel);
|
||||
static void expectSilenceBetween(const GeneratedAudio& audio, float startTimeSeconds, float stopTimeSeconds, const std::string& testLabel);
|
||||
|
||||
|
||||
void findLongestSilence_test() {
|
||||
testTones();
|
||||
testToneWithPause();
|
||||
testSilence();
|
||||
testNoise();
|
||||
}
|
||||
|
||||
|
||||
static void testTones() {
|
||||
for (int frequency = 440; frequency < 1600; frequency += 300) {
|
||||
std::stringstream messageBuilder;
|
||||
messageBuilder << "Should not find silence in tone with frequency " << frequency << " HZ.";
|
||||
|
||||
auto audioTone = makeAudio([frequency](float t) {
|
||||
// Also set the amplitude to 0.2f (to more closely match mic input).
|
||||
return std::sin(t * static_cast<float>(frequency)) * 0.2f;
|
||||
}, 15000, 10.0f);
|
||||
|
||||
expectNoSilence(audioTone, messageBuilder.str());
|
||||
}
|
||||
|
||||
auto lowFrequencyTone = makeAudio([](float t) {
|
||||
return std::sin(t * 8) * 0.3f;
|
||||
}, 15000, 10.0f);
|
||||
expectSilenceBetween(lowFrequencyTone, 0.0f, 10.0f, "Should find silence in a very low-frequency tone");
|
||||
}
|
||||
|
||||
static void testToneWithPause() {
|
||||
auto audioToneWithPause = makeAudio([](float t) {
|
||||
if (t < 5.0f || t > 6.0f) {
|
||||
return std::sin(t * 880);
|
||||
} else {
|
||||
return 0.0f;
|
||||
}
|
||||
}, 15000, 11.0f);
|
||||
expectSilenceBetween(audioToneWithPause, 5.0f, 6.0f, "Should find silence when completely silent in a region");
|
||||
|
||||
auto audioToneWithTwoPauses = makeAudio([](float t) {
|
||||
if (t < 1.0f || (t > 8.0f && t < 10.0f)) {
|
||||
return 0.0f;
|
||||
} else {
|
||||
return std::sin(t * 880);
|
||||
}
|
||||
}, 15000, 20.0f);
|
||||
expectSilenceBetween(audioToneWithPause, 5.0f, 6.0f, "Should find silence when completely silent in a region");
|
||||
}
|
||||
|
||||
static void testSilence() {
|
||||
auto silence = makeAudio([](float t) {
|
||||
return 0.0f;
|
||||
}, 16000, 10.0f);
|
||||
expectSilenceBetween(silence, 0.0f, 10.0f, "Should find silence in a completely silent signal");
|
||||
}
|
||||
|
||||
static void testNoise() {
|
||||
std::minstd_rand randomness {2};
|
||||
std::uniform_real_distribution noiseGenerator {-1.0, 1.0};
|
||||
auto quietNoise = makeAudio([&](float t) {
|
||||
return noiseGenerator(randomness) * 0.02f;
|
||||
}, 16000, 5.0f);
|
||||
expectSilenceBetween(quietNoise, 0.0f, 5.0f, "Should find silence in a tone with low-amplitude noise");
|
||||
}
|
||||
|
||||
|
||||
static void fail(const std::string& message) {
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
static GeneratedAudio makeAudio(const AudioGenerator& generator, int sampleRate, float duration) {
|
||||
std::vector<float> result { };
|
||||
|
||||
int numSamples = static_cast<int>(static_cast<float>(sampleRate) * duration);
|
||||
for (int i = 0; i < numSamples; i++) {
|
||||
float time = static_cast<float>(i) / static_cast<float>(sampleRate);
|
||||
result.push_back(generator(time));
|
||||
}
|
||||
|
||||
return {
|
||||
.data=result,
|
||||
.sampleRate=sampleRate,
|
||||
.sampleCount=numSamples,
|
||||
};
|
||||
}
|
||||
|
||||
static void logTestPass(const std::string& message) {
|
||||
LOGI("Test PASS: %s", message.c_str());
|
||||
}
|
||||
|
||||
static float samplesToSeconds(int samples, int sampleRate) {
|
||||
return static_cast<float>(samples) / static_cast<float>(sampleRate);
|
||||
}
|
||||
|
||||
static void expectNoSilence(const GeneratedAudio& audio, const std::string& testLabel) {
|
||||
auto silence = findLongestSilence(
|
||||
audio.data,
|
||||
audio.sampleRate,
|
||||
0.02f,
|
||||
audio.sampleCount
|
||||
);
|
||||
if (silence.isValid) {
|
||||
std::stringstream errorBuilder;
|
||||
float startSeconds = samplesToSeconds(silence.start, audio.sampleRate);
|
||||
float stopSeconds = samplesToSeconds(silence.end, audio.sampleRate);
|
||||
errorBuilder << "Error: Found silence between " << startSeconds << "s and " << stopSeconds << "s";
|
||||
errorBuilder << ": " << testLabel;
|
||||
fail(errorBuilder.str());
|
||||
}
|
||||
|
||||
logTestPass(testLabel);
|
||||
}
|
||||
|
||||
static void expectSilenceBetween(const GeneratedAudio& audio, float startTimeSeconds, float stopTimeSeconds, const std::string& testLabel) {
|
||||
auto silenceResult = findLongestSilence(
|
||||
audio.data,
|
||||
audio.sampleRate,
|
||||
0.02f,
|
||||
audio.sampleCount
|
||||
);
|
||||
|
||||
if (!silenceResult.isValid) {
|
||||
fail("Error: No silence found: " + testLabel);
|
||||
}
|
||||
|
||||
auto checkEndpoint = [&] (int actualValueSamples, float expectedValueSeconds, const std::string& description) {
|
||||
float actualValueSeconds = samplesToSeconds(actualValueSamples, audio.sampleRate);
|
||||
float tolerance = 0.1f; // 100ms
|
||||
if (std::abs(expectedValueSeconds - actualValueSeconds) > tolerance) {
|
||||
std::stringstream messageBuilder;
|
||||
messageBuilder << "Error: Silence " << description << " mismatch: ";
|
||||
messageBuilder << "got " << actualValueSeconds << "s expected " << expectedValueSeconds << "s. ";
|
||||
messageBuilder << testLabel;
|
||||
fail(messageBuilder.str());
|
||||
}
|
||||
};
|
||||
|
||||
checkEndpoint(silenceResult.start, startTimeSeconds, "start time");
|
||||
checkEndpoint(silenceResult.end, stopTimeSeconds, "stop time");
|
||||
|
||||
logTestPass(testLabel);
|
||||
}
|
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
void findLongestSilence_test();
|
125
packages/app-mobile/android/app/src/main/cpp/whisperWrapper.cpp
Normal file
125
packages/app-mobile/android/app/src/main/cpp/whisperWrapper.cpp
Normal file
@@ -0,0 +1,125 @@
|
||||
// Write C++ code here.
|
||||
//
|
||||
// Do not forget to dynamically load the C++ library into your application.
|
||||
//
|
||||
// For instance,
|
||||
//
|
||||
// In MainActivity.java:
|
||||
// static {
|
||||
// System.loadLibrary("joplin");
|
||||
// }
|
||||
//
|
||||
// Or, in MainActivity.kt:
|
||||
// companion object {
|
||||
// init {
|
||||
// System.loadLibrary("joplin")
|
||||
// }
|
||||
// }
|
||||
#include <jni.h>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <sstream>
|
||||
#include <android/log.h>
|
||||
#include "whisper.h"
|
||||
#include "utils/WhisperSession.h"
|
||||
#include "utils/androidUtil.h"
|
||||
#include "utils/findLongestSilence_test.h"
|
||||
|
||||
void log_android(enum ggml_log_level level, const char* message, void* user_data) {
|
||||
android_LogPriority priority = level == 4 ? ANDROID_LOG_ERROR : ANDROID_LOG_INFO;
|
||||
__android_log_print(priority, "Whisper::JNI::cpp", "%s", message);
|
||||
}
|
||||
|
||||
jstring stringToJava(JNIEnv *env, const std::string& source) {
|
||||
return env->NewStringUTF(source.c_str());
|
||||
}
|
||||
|
||||
std::string stringToCXX(JNIEnv *env, jstring jString) {
|
||||
const char *jStringChars = env->GetStringUTFChars(jString, nullptr);
|
||||
std::string result { jStringChars };
|
||||
env->ReleaseStringUTFChars(jString, jStringChars);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void throwException(JNIEnv *env, const std::string& message) {
|
||||
jclass errorClass = env->FindClass("java/lang/Exception");
|
||||
env->ThrowNew(errorClass, message.c_str());
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_init(
|
||||
JNIEnv *env,
|
||||
jobject thiz,
|
||||
jstring modelPath,
|
||||
jstring language,
|
||||
jstring prompt
|
||||
) {
|
||||
whisper_log_set(log_android, nullptr);
|
||||
|
||||
try {
|
||||
auto *pSession = new WhisperSession(
|
||||
stringToCXX(env, modelPath), stringToCXX(env, language), stringToCXX(env, prompt)
|
||||
);
|
||||
return (jlong) pSession;
|
||||
} catch (const std::exception& exception) {
|
||||
LOGW("Failed to init whisper: %s", exception.what());
|
||||
throwException(env, exception.what());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_free(JNIEnv *env, jobject thiz,
|
||||
jlong pointer) {
|
||||
std::free(reinterpret_cast<WhisperSession *>(pointer));
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_fullTranscribe(JNIEnv *env,
|
||||
jobject thiz,
|
||||
jlong pointer,
|
||||
jfloatArray audio_data) {
|
||||
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
|
||||
jfloat *pAudioData = env->GetFloatArrayElements(audio_data, nullptr);
|
||||
jsize lenAudioData = env->GetArrayLength(audio_data);
|
||||
std::string result;
|
||||
|
||||
try {
|
||||
LOGD("Starting Whisper, transcribe %d", lenAudioData);
|
||||
result = pSession->transcribeNextChunk(pAudioData, lenAudioData);
|
||||
auto preview = pSession->getPreview();
|
||||
LOGD("Ran Whisper. Got %s (preview %s)", result.c_str(), preview.c_str());
|
||||
} catch (const std::exception& exception) {
|
||||
LOGW("Failed to run whisper: %s", exception.what());
|
||||
throwException(env, exception.what());
|
||||
}
|
||||
|
||||
// JNI_ABORT: "free the buffer without copying back the possible changes", pass 0 to copy
|
||||
// changes (there should be no changes)
|
||||
env->ReleaseFloatArrayElements(audio_data, pAudioData, JNI_ABORT);
|
||||
|
||||
return stringToJava(env, result);
|
||||
}
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_getPreview(
|
||||
JNIEnv *env, jobject thiz, jlong pointer
|
||||
) {
|
||||
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
|
||||
return stringToJava(env, pSession->getPreview());
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_runTests(JNIEnv *env, jobject thiz) {
|
||||
try {
|
||||
findLongestSilence_test();
|
||||
} catch (const std::exception& exception) {
|
||||
LOGW("Failed to run tests: %s", exception.what());
|
||||
throwException(env, exception.what());
|
||||
}
|
||||
}
|
@@ -21,7 +21,7 @@ class AudioRecorder(context: Context) : Closeable {
|
||||
private var bufferWriteOffset = 0
|
||||
|
||||
// Accessor must not modify result
|
||||
val bufferedData: FloatArray get() = buffer.sliceArray(0 until bufferWriteOffset)
|
||||
private val bufferedData: FloatArray get() = buffer.sliceArray(0 until bufferWriteOffset)
|
||||
val bufferLengthSeconds: Double get() = bufferWriteOffset.toDouble() / sampleRate
|
||||
|
||||
init {
|
||||
@@ -74,11 +74,16 @@ class AudioRecorder(context: Context) : Closeable {
|
||||
}
|
||||
|
||||
// Pulls all available data from the audio recorder's buffer
|
||||
fun pullAvailable() {
|
||||
return read(maxBufferSize, AudioRecord.READ_NON_BLOCKING)
|
||||
fun pullAvailable(): FloatArray {
|
||||
read(maxBufferSize, AudioRecord.READ_NON_BLOCKING)
|
||||
|
||||
val result = bufferedData
|
||||
buffer.fill(0.0f, 0, maxBufferSize);
|
||||
bufferWriteOffset = 0
|
||||
return result
|
||||
}
|
||||
|
||||
fun pullNextSeconds(seconds: Double) {
|
||||
fun pullNextSeconds(seconds: Double):FloatArray {
|
||||
val remainingSize = maxBufferSize - bufferWriteOffset
|
||||
val requestedSize = (seconds * sampleRate).toInt()
|
||||
|
||||
@@ -87,7 +92,8 @@ class AudioRecorder(context: Context) : Closeable {
|
||||
advanceStartBySamples(maxBufferSize / 3)
|
||||
}
|
||||
|
||||
return read(requestedSize, AudioRecord.READ_BLOCKING)
|
||||
read(requestedSize, AudioRecord.READ_BLOCKING)
|
||||
return pullAvailable()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
|
@@ -0,0 +1,54 @@
|
||||
package net.cozic.joplin.audio
|
||||
|
||||
import java.io.Closeable
|
||||
|
||||
class NativeWhisperLib(
|
||||
modelPath: String,
|
||||
languageCode: String,
|
||||
prompt: String,
|
||||
) : Closeable {
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("joplin")
|
||||
}
|
||||
|
||||
external fun runTests(): Unit;
|
||||
|
||||
// TODO: The example whisper.cpp project transfers pointers as Longs to the Kotlin code.
|
||||
// This seems unsafe. Try changing how this is managed.
|
||||
private external fun init(modelPath: String, languageCode: String, prompt: String): Long;
|
||||
private external fun free(pointer: Long): Unit;
|
||||
|
||||
private external fun fullTranscribe(pointer: Long, audioData: FloatArray): String;
|
||||
private external fun getPreview(pointer: Long): String;
|
||||
}
|
||||
|
||||
private var closed = false
|
||||
private val pointer: Long = init(modelPath, languageCode, prompt)
|
||||
|
||||
fun transcribe(audioData: FloatArray): String {
|
||||
if (closed) {
|
||||
throw Exception("Cannot transcribe using a closed session")
|
||||
}
|
||||
|
||||
return fullTranscribe(pointer, audioData)
|
||||
}
|
||||
|
||||
fun getPreview(): String {
|
||||
if (closed) {
|
||||
throw Exception("Cannot get preview from a closed session")
|
||||
}
|
||||
|
||||
return getPreview(pointer)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (closed) {
|
||||
throw Exception("Cannot close a whisper session twice")
|
||||
}
|
||||
|
||||
closed = true
|
||||
free(pointer)
|
||||
}
|
||||
|
||||
}
|
@@ -1,110 +1,33 @@
|
||||
package net.cozic.joplin.audio
|
||||
|
||||
import ai.onnxruntime.OnnxTensor
|
||||
import ai.onnxruntime.OrtEnvironment
|
||||
import ai.onnxruntime.OrtSession
|
||||
import ai.onnxruntime.extensions.OrtxPackage
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.Closeable
|
||||
import java.nio.FloatBuffer
|
||||
import java.nio.IntBuffer
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class SpeechToTextConverter(
|
||||
modelPath: String,
|
||||
locale: String,
|
||||
prompt: String,
|
||||
recorderFactory: AudioRecorderFactory,
|
||||
private val environment: OrtEnvironment,
|
||||
context: Context,
|
||||
) : Closeable {
|
||||
private val recorder = recorderFactory(context)
|
||||
private val session: OrtSession = environment.createSession(
|
||||
modelPath,
|
||||
OrtSession.SessionOptions().apply {
|
||||
// Needed for audio decoding
|
||||
registerCustomOpLibrary(OrtxPackage.getLibraryPath())
|
||||
},
|
||||
)
|
||||
private val languageCode = Regex("_.*").replace(locale, "")
|
||||
private val decoderInputIds = when (languageCode) {
|
||||
// Add 50363 to the end to omit timestamps
|
||||
"en" -> intArrayOf(50258, 50259, 50359)
|
||||
"fr" -> intArrayOf(50258, 50265, 50359)
|
||||
"es" -> intArrayOf(50258, 50262, 50359)
|
||||
"de" -> intArrayOf(50258, 50261, 50359)
|
||||
"it" -> intArrayOf(50258, 50274, 50359)
|
||||
"nl" -> intArrayOf(50258, 50271, 50359)
|
||||
"ko" -> intArrayOf(50258, 50264, 50359)
|
||||
"th" -> intArrayOf(50258, 50289, 50359)
|
||||
"ru" -> intArrayOf(50258, 50263, 50359)
|
||||
"pt" -> intArrayOf(50258, 50267, 50359)
|
||||
"pl" -> intArrayOf(50258, 50269, 50359)
|
||||
"id" -> intArrayOf(50258, 50275, 50359)
|
||||
"hi" -> intArrayOf(50258, 50276, 50359)
|
||||
// Let Whisper guess the language
|
||||
else -> intArrayOf(50258)
|
||||
}
|
||||
private var whisper = NativeWhisperLib(
|
||||
modelPath,
|
||||
languageCode,
|
||||
prompt,
|
||||
)
|
||||
|
||||
fun start() {
|
||||
recorder.start()
|
||||
}
|
||||
|
||||
private fun getInputs(data: FloatArray): MutableMap<String, OnnxTensor> {
|
||||
fun intTensor(value: Int) = OnnxTensor.createTensor(
|
||||
environment,
|
||||
IntBuffer.wrap(intArrayOf(value)),
|
||||
longArrayOf(1),
|
||||
)
|
||||
fun floatTensor(value: Float) = OnnxTensor.createTensor(
|
||||
environment,
|
||||
FloatBuffer.wrap(floatArrayOf(value)),
|
||||
longArrayOf(1),
|
||||
)
|
||||
val audioPcmTensor = OnnxTensor.createTensor(
|
||||
environment,
|
||||
FloatBuffer.wrap(data),
|
||||
longArrayOf(1, data.size.toLong()),
|
||||
)
|
||||
val decoderInputIdsTensor = OnnxTensor.createTensor(
|
||||
environment,
|
||||
IntBuffer.wrap(decoderInputIds),
|
||||
longArrayOf(1, decoderInputIds.size.toLong())
|
||||
)
|
||||
|
||||
return mutableMapOf(
|
||||
"audio_pcm" to audioPcmTensor,
|
||||
"max_length" to intTensor(412),
|
||||
"min_length" to intTensor(0),
|
||||
"num_return_sequences" to intTensor(1),
|
||||
"num_beams" to intTensor(1),
|
||||
"length_penalty" to floatTensor(1.1f),
|
||||
"repetition_penalty" to floatTensor(3f),
|
||||
"decoder_input_ids" to decoderInputIdsTensor,
|
||||
|
||||
// Required for timestamps
|
||||
"logits_processor" to intTensor(1)
|
||||
)
|
||||
}
|
||||
|
||||
// TODO .get() fails on older Android versions
|
||||
@SuppressLint("NewApi")
|
||||
private fun convert(data: FloatArray): String {
|
||||
val (inputs, convertInputsTime) = measureTimedValue {
|
||||
getInputs(data)
|
||||
}
|
||||
val (outputs, getOutputsTime) = measureTimedValue {
|
||||
session.run(inputs, setOf("str"))
|
||||
}
|
||||
val mainOutput = outputs.get("str").get().value as Array<Array<String>>
|
||||
outputs.close()
|
||||
|
||||
Log.i("Whisper", "Converted ${data.size / 16000}s of data in ${
|
||||
getOutputsTime.toString(DurationUnit.SECONDS, 2)
|
||||
} converted inputs in ${convertInputsTime.inWholeMilliseconds}ms")
|
||||
return mainOutput[0][0]
|
||||
Log.d("Whisper", "Pre-transcribe data of size ${data.size}")
|
||||
val result = whisper.transcribe(data)
|
||||
Log.d("Whisper", "Post transcribe. Got $result")
|
||||
return result;
|
||||
}
|
||||
|
||||
fun dropFirstSeconds(seconds: Double) {
|
||||
@@ -114,23 +37,26 @@ class SpeechToTextConverter(
|
||||
|
||||
val bufferLengthSeconds: Double get() = recorder.bufferLengthSeconds
|
||||
|
||||
fun expandBufferAndConvert(seconds: Double): String {
|
||||
recorder.pullNextSeconds(seconds)
|
||||
// Also pull any extra available data, in case the speech-to-text converter
|
||||
// is lagging behind the audio recorder.
|
||||
recorder.pullAvailable()
|
||||
|
||||
return convert(recorder.bufferedData)
|
||||
fun convertNext(seconds: Double): String {
|
||||
val buffer = recorder.pullNextSeconds(seconds)
|
||||
val result = convert(buffer)
|
||||
dropFirstSeconds(seconds)
|
||||
return result
|
||||
}
|
||||
|
||||
// Converts as many seconds of buffered data as possible, without waiting
|
||||
fun expandBufferAndConvert(): String {
|
||||
recorder.pullAvailable()
|
||||
return convert(recorder.bufferedData)
|
||||
fun convertRemaining(): String {
|
||||
val buffer = recorder.pullAvailable()
|
||||
return convert(buffer)
|
||||
}
|
||||
|
||||
fun getPreview(): String {
|
||||
return whisper.getPreview()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
Log.d("Whisper", "Close")
|
||||
recorder.close()
|
||||
session.close()
|
||||
whisper.close()
|
||||
}
|
||||
}
|
@@ -1,6 +1,5 @@
|
||||
package net.cozic.joplin.audio
|
||||
|
||||
import ai.onnxruntime.OrtEnvironment
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.LifecycleEventListener
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
@@ -24,7 +23,6 @@ class SpeechToTextPackage : ReactPackage {
|
||||
class SpeechToTextModule(
|
||||
private var context: ReactApplicationContext,
|
||||
) : ReactContextBaseJavaModule(context), LifecycleEventListener {
|
||||
private var environment: OrtEnvironment? = null
|
||||
private val executorService: ExecutorService = Executors.newFixedThreadPool(1)
|
||||
private val sessionManager = SpeechToTextSessionManager(executorService)
|
||||
|
||||
@@ -32,21 +30,24 @@ class SpeechToTextPackage : ReactPackage {
|
||||
|
||||
override fun onHostResume() { }
|
||||
override fun onHostPause() { }
|
||||
override fun onHostDestroy() {
|
||||
environment?.close()
|
||||
override fun onHostDestroy() { }
|
||||
|
||||
@ReactMethod
|
||||
fun runTests(promise: Promise) {
|
||||
try {
|
||||
NativeWhisperLib.runTests()
|
||||
promise.resolve(true)
|
||||
} catch (exception: Throwable) {
|
||||
promise.reject(exception)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun openSession(modelPath: String, locale: String, promise: Promise) {
|
||||
fun openSession(modelPath: String, locale: String, prompt: String, promise: Promise) {
|
||||
val appContext = context.applicationContext
|
||||
// Initialize environment as late as possible:
|
||||
val ortEnvironment = environment ?: OrtEnvironment.getEnvironment()
|
||||
if (environment != null) {
|
||||
environment = ortEnvironment
|
||||
}
|
||||
|
||||
try {
|
||||
val sessionId = sessionManager.openSession(modelPath, locale, ortEnvironment, appContext)
|
||||
val sessionId = sessionManager.openSession(modelPath, locale, prompt, appContext)
|
||||
promise.resolve(sessionId)
|
||||
} catch (exception: Throwable) {
|
||||
promise.reject(exception)
|
||||
@@ -69,8 +70,8 @@ class SpeechToTextPackage : ReactPackage {
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun expandBufferAndConvert(sessionId: Int, duration: Double, promise: Promise) {
|
||||
sessionManager.expandBufferAndConvert(sessionId, duration, promise)
|
||||
fun convertNext(sessionId: Int, duration: Double, promise: Promise) {
|
||||
sessionManager.convertNext(sessionId, duration, promise)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
@@ -78,6 +79,11 @@ class SpeechToTextPackage : ReactPackage {
|
||||
sessionManager.convertAvailable(sessionId, promise)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getPreview(sessionId: Int, promise: Promise) {
|
||||
sessionManager.getPreview(sessionId, promise)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun closeSession(sessionId: Int, promise: Promise) {
|
||||
sessionManager.closeSession(sessionId, promise)
|
||||
|
@@ -1,6 +1,5 @@
|
||||
package net.cozic.joplin.audio
|
||||
|
||||
import ai.onnxruntime.OrtEnvironment
|
||||
import android.content.Context
|
||||
import com.facebook.react.bridge.Promise
|
||||
import java.util.concurrent.Executor
|
||||
@@ -21,13 +20,13 @@ class SpeechToTextSessionManager(
|
||||
fun openSession(
|
||||
modelPath: String,
|
||||
locale: String,
|
||||
environment: OrtEnvironment,
|
||||
prompt: String,
|
||||
context: Context,
|
||||
): Int {
|
||||
val sessionId = nextSessionId++
|
||||
sessions[sessionId] = SpeechToTextSession(
|
||||
SpeechToTextConverter(
|
||||
modelPath, locale, recorderFactory = AudioRecorder.factory, environment, context,
|
||||
modelPath, locale, prompt, recorderFactory = AudioRecorder.factory, context,
|
||||
)
|
||||
)
|
||||
return sessionId
|
||||
@@ -87,9 +86,9 @@ class SpeechToTextSessionManager(
|
||||
}
|
||||
|
||||
// Waits for the next [duration] seconds to become available, then converts
|
||||
fun expandBufferAndConvert(sessionId: Int, duration: Double, promise: Promise) {
|
||||
fun convertNext(sessionId: Int, duration: Double, promise: Promise) {
|
||||
this.concurrentWithSession(sessionId, promise::reject) { session ->
|
||||
val result = session.converter.expandBufferAndConvert(duration)
|
||||
val result = session.converter.convertNext(duration)
|
||||
promise.resolve(result)
|
||||
}
|
||||
}
|
||||
@@ -97,7 +96,14 @@ class SpeechToTextSessionManager(
|
||||
// Converts all available recorded data
|
||||
fun convertAvailable(sessionId: Int, promise: Promise) {
|
||||
this.concurrentWithSession(sessionId, promise::reject) { session ->
|
||||
val result = session.converter.expandBufferAndConvert()
|
||||
val result = session.converter.convertRemaining()
|
||||
promise.resolve(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPreview(sessionId: Int, promise: Promise) {
|
||||
this.concurrentWithSession(sessionId, promise::reject) { session ->
|
||||
val result = session.converter.getPreview()
|
||||
promise.resolve(result)
|
||||
}
|
||||
}
|
||||
|
9
packages/app-mobile/android/vendor/.gitignore
vendored
Normal file
9
packages/app-mobile/android/vendor/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
whisper.cpp/.gitmodules
|
||||
whisper.cpp/scripts/
|
||||
whisper.cpp/samples/
|
||||
whisper.cpp/tests/
|
||||
whisper.cpp/models/
|
||||
whisper.cpp/examples/
|
||||
whisper.cpp/.*/
|
||||
whisper.cpp/bindings/
|
||||
whisper.cpp/**/*.Dockerfile
|
7
packages/app-mobile/android/vendor/README.md
vendored
Normal file
7
packages/app-mobile/android/vendor/README.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Vendored Android packages
|
||||
|
||||
This directory contains upstream packages that can't be added as direct dependencies (e.g. through `npm`).
|
||||
|
||||
## whisper.cpp
|
||||
|
||||
`whisper.cpp` provides voice typing capabilities. It can be updated by replacing the contents of the `whisper.cpp` directory with the latest content from https://github.com/ggerganov/whisper.cpp. To decrease the size of the `whisper.cpp` directory, some files are ignored by the `.gitignore`.
|
60
packages/app-mobile/android/vendor/whisper.cpp/.gitignore
vendored
Normal file
60
packages/app-mobile/android/vendor/whisper.cpp/.gitignore
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
*.o
|
||||
*.a
|
||||
*.d
|
||||
.cache/
|
||||
.coreml/
|
||||
.test/
|
||||
.venv/
|
||||
.vs/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
.vimspector.json
|
||||
/CMakeSettings.json
|
||||
/talk-llama.dSYM/
|
||||
|
||||
build/
|
||||
build-*/
|
||||
|
||||
# SPM
|
||||
.build/
|
||||
.swiftpm
|
||||
*.metallib
|
||||
|
||||
ggml-metal-embed.metal
|
||||
ggml-metal-embed.metal.tmp
|
||||
|
||||
/main
|
||||
/stream
|
||||
/command
|
||||
/talk
|
||||
/talk-llama
|
||||
/bench
|
||||
/quantize
|
||||
/server
|
||||
/lsp
|
||||
|
||||
arm_neon.h
|
||||
sync.sh
|
||||
libwhisper.a
|
||||
libwhisper.so
|
||||
compile_commands.json
|
||||
|
||||
examples/arm_neon.h
|
||||
examples/whisper.objc/whisper.objc.xcodeproj/xcshareddata
|
||||
examples/whisper.objc/whisper.objc.xcodeproj/xcuserdata/
|
||||
examples/whisper.objc/whisper.objc.xcodeproj/project.xcworkspace/xcuserdata
|
||||
|
||||
extra/bench-gg.txt
|
||||
|
||||
models/*.mlmodel
|
||||
models/*.mlmodelc
|
||||
models/*.mlpackage
|
||||
bindings/java/.gradle/
|
||||
bindings/java/.idea/
|
||||
.idea/
|
||||
|
||||
benchmark_results.csv
|
||||
cmake-build-debug/
|
||||
.cxx/
|
||||
.gradle/
|
||||
local.properties
|
510
packages/app-mobile/android/vendor/whisper.cpp/AUTHORS
vendored
Normal file
510
packages/app-mobile/android/vendor/whisper.cpp/AUTHORS
vendored
Normal file
@@ -0,0 +1,510 @@
|
||||
# date: Tue Feb 4 13:03:35 EET 2025
|
||||
# this file is auto-generated by scripts/gen-authors.sh
|
||||
|
||||
0/0 <zero@imaskeleton.me>
|
||||
0cc4m <picard12@live.de>
|
||||
0xsourcecode <134374803+0xsourcecode@users.noreply.github.com>
|
||||
65a <10104049+65a@users.noreply.github.com>
|
||||
AIWintermuteAI <32562299+AIWintermuteAI@users.noreply.github.com>
|
||||
AT <manyoso@users.noreply.github.com>
|
||||
Aarni Koskela <akx@iki.fi>
|
||||
Aaron Pham <29749331+aarnphm@users.noreply.github.com>
|
||||
Aaron Taylor <aaron@exphat.com>
|
||||
Abhilash Majumder <30946547+abhilash1910@users.noreply.github.com>
|
||||
Abitofevrything <54505189+abitofevrything@users.noreply.github.com>
|
||||
Adam Jones <domdomegg+git@gmail.com>
|
||||
Adrien Gallouët <adrien@gallouet.fr>
|
||||
Adrien Gallouët <angt@huggingface.co>
|
||||
AfryMask <AfryMask@163.com>
|
||||
Ahmad Bilal <ahmad.bilal@empglabs.com>
|
||||
Ahmad Tameem <113388789+Tameem-10xE@users.noreply.github.com>
|
||||
AidanBeltonS <87009434+AidanBeltonS@users.noreply.github.com>
|
||||
AidanBeltonS <aidan.belton@codeplay.com>
|
||||
Akarshan Biswas <akarshan.biswas@gmail.com>
|
||||
Akarshan Biswas <akarshanbiswas@fedoraproject.org>
|
||||
Akash Mahajan <akash7190@gmail.com>
|
||||
Akash Mahajan <akashmjn@stanford.edu>
|
||||
Al Hoang <3811822-hoanga@users.noreply.gitlab.com>
|
||||
Alan <unknown>
|
||||
Albert Jin <albert.jin@gmail.com>
|
||||
Alberto Cabrera Pérez <alberto.cabrera@codeplay.com>
|
||||
Alberto Cabrera Pérez <alberto.cabrera@intel.com>
|
||||
Aleksander Andrzejewski <18704749+aleksanderandrzejewski@users.noreply.github.com>
|
||||
Alex Azarov <alex@azarov.by>
|
||||
Alex Bacart <13940752+alex-bacart@users.noreply.github.com>
|
||||
Alex Evgrashin <aevgrashin@yandex.ru>
|
||||
Alex O'Connell <35843486+acon96@users.noreply.github.com>
|
||||
Alexandr Graschenkov <alexandr.graschenkov91@gmail.com>
|
||||
Alexandru Mariuti <alex@mariuti.com>
|
||||
Alexey Kharlamov <alexey@kharlamov.biz>
|
||||
Alfredo Montesinos <alfredo.montesinos@g.austincc.edu>
|
||||
Ali Alameh <ali.alameh@isae.edu.lb>
|
||||
Alter <0x7c48@gmail.com>
|
||||
Ananta Bastola <anantarajbastola@gmail.com>
|
||||
Andreas Kieslinger <47689530+aendk@users.noreply.github.com>
|
||||
Andreas Lubbe <git@lubbe.org>
|
||||
Andreu Huguet <andreuhuguet@gmail.com>
|
||||
Andrew Huynh <a5thuynh@gmail.com>
|
||||
Andrew Minh Nguyen <40281306+amqdn@users.noreply.github.com>
|
||||
Andrew S <andrews54757@gmail.com>
|
||||
Andy Maloney <asmaloney@gmail.com>
|
||||
Anton Kostin <masguit42@users.noreply.github.com>
|
||||
Artyom Mezin <psycho.fading@gmail.com>
|
||||
Asad Memon <asad.lionpk@gmail.com>
|
||||
Ashraful Islam <ashraful.meche@gmail.com>
|
||||
AsukaMinato <asukaminato@nyan.eu.org>
|
||||
AustinMroz <austinmroz@utexas.edu>
|
||||
Avik Sengupta <avik@sengupta.net>
|
||||
Bader-eddine Ouaich <49657842+baderouaich@users.noreply.github.com>
|
||||
Baffin Lee <baffinlee@gmail.com>
|
||||
Ben Ashbaugh <ben.ashbaugh@intel.com>
|
||||
Ben Nortier <bjnortier@gmail.com>
|
||||
Benjamin Heiniger <benjamin.heiniger@bluewin.ch>
|
||||
Bernhard M. Wiedemann <githubbmwprimary@lsmod.de>
|
||||
Binozo <70137898+Binozo@users.noreply.github.com>
|
||||
Bo-Yi Wu <appleboy.tw@gmail.com>
|
||||
Boris Bliznioukov <blib@mail.com>
|
||||
Borislav Stanimirov <b.stanimirov@abv.bg>
|
||||
Brad Murray <59848399+bradmurray-dt@users.noreply.github.com>
|
||||
Brian Murray <brian@bmurray.ca>
|
||||
CRD716 <crd716@gmail.com>
|
||||
Canis Lupus <Canis-UK@users.noreply.github.com>
|
||||
Carlos Zoido <mrgalleta@gmail.com>
|
||||
Carolinabanana <140120812+Carolinabanana@users.noreply.github.com>
|
||||
CarterLi999 <664681047@qq.com>
|
||||
ChangSeok Oh <shivamidow@users.noreply.github.com>
|
||||
Changyeon Kim <cyzero.kim@samsung.com>
|
||||
Chaoqun <27287694+OpenWaygate@users.noreply.github.com>
|
||||
Charles Xu <63788048+chaxu01@users.noreply.github.com>
|
||||
Charles Xu <charles.xu@arm.com>
|
||||
Chen Xi <xi2.chen@intel.com>
|
||||
Chen Xi <xixichen08@foxmail.com>
|
||||
Chenguang Li <87689256+noemotiovon@users.noreply.github.com>
|
||||
Chia-Hsiang Cheng <88014292+garychia@users.noreply.github.com>
|
||||
Chidi Williams <williamschidi1@gmail.com>
|
||||
Chris Elrod <elrodc@gmail.com>
|
||||
Christian <12550267+iceychris@users.noreply.github.com>
|
||||
Christian Kastner <ckk@kvr.at>
|
||||
Clifford Heath <clifford.heath@gmail.com>
|
||||
Clint Herron <hanclinto@gmail.com>
|
||||
Colin <github@whoisc.cc>
|
||||
Conrad Kramer <conrad@conradkramer.com>
|
||||
Corey Earwood <iamcgn+github@gmail.com>
|
||||
CrispStrobe <154636388+CrispStrobe@users.noreply.github.com>
|
||||
DAN™ <dranger003@gmail.com>
|
||||
DGdev91 <DGdev91@users.noreply.github.com>
|
||||
Damian Czaja <trojan295@protonmail.com>
|
||||
Dan Johansson <164997844+eddnjjn@users.noreply.github.com>
|
||||
Dan Johansson <dan.johansson@arm.com>
|
||||
Daniel Bevenius <daniel.bevenius@gmail.com>
|
||||
Daniel Valdivia <18384552+dvaldivia@users.noreply.github.com>
|
||||
Daniel Ziegenberg <daniel@ziegenberg.at>
|
||||
Daniele <57776841+daniandtheweb@users.noreply.github.com>
|
||||
Dave <dave-fl@users.noreply.github.com>
|
||||
Dave Airlie <airlied@gmail.com>
|
||||
Dave Airlie <airlied@redhat.com>
|
||||
Daven Sanassy <daven@vochlea.co.uk>
|
||||
David <dnhkng@gmail.com>
|
||||
David Thorpe <djt@mutablelogic.com>
|
||||
DavidKorczynski <david@adalogics.com>
|
||||
Davidson Francis <davidsondfgl@gmail.com>
|
||||
Dener Stassun <denerstassun@gmail.com>
|
||||
Dibakar Gope <dibakar.gope@arm.com>
|
||||
Didzis Gosko <didzis@users.noreply.github.com>
|
||||
Diego Devesa <slarengh@gmail.com>
|
||||
Digipom <admin@digipom.com>
|
||||
Dimo <dimo@ieee.org>
|
||||
Djip007 <3705339+Djip007@users.noreply.github.com>
|
||||
Djip007 <djip.perois@free.fr>
|
||||
Dody Suria Wijaya <dodysw@gmail.com>
|
||||
Dou Xinpeng <15529241576@163.com>
|
||||
Dou Xinpeng <81913537+Dou-Git@users.noreply.github.com>
|
||||
Dr. Tom Murphy VII Ph.D <499244+tom7@users.noreply.github.com>
|
||||
Duncan McConnell <ddmcconnell4@gmail.com>
|
||||
Egor Egorov <me@egorfine.com>
|
||||
Elkana Bardugo <ttv200@gmail.com>
|
||||
Emmanuel Schmidbauer <eschmidbauer@gmail.com>
|
||||
Engininja2 <139037756+Engininja2@users.noreply.github.com>
|
||||
Eric Curtin <ericcurtin17@gmail.com>
|
||||
Eric Swanson <eswanson@alloscomp.com>
|
||||
Eric Tendian <erictendian@gmail.com>
|
||||
Eric Zhang <34133756+EZForever@users.noreply.github.com>
|
||||
Erik Scholz <Green-Sky@users.noreply.github.com>
|
||||
Evan Jones <evan.q.jones@gmail.com>
|
||||
Evan Martin <evan.martin@gmail.com>
|
||||
Eve <139727413+netrunnereve@users.noreply.github.com>
|
||||
Evgeny Kuznetsov <evgeny@kuznetsov.md>
|
||||
F1L1P <78918286+F1L1Pv2@users.noreply.github.com>
|
||||
Faisal Zaghloul <quic_fzaghlou@quicinc.com>
|
||||
Fangjun Kuang <csukuangfj@gmail.com>
|
||||
Felix <stenbackfelix@gmail.com>
|
||||
Finn Voorhees <finnvoorhees@gmail.com>
|
||||
FirstTimeEZ <179362031+FirstTimeEZ@users.noreply.github.com>
|
||||
FlippFuzz <41221030+FlippFuzz@users.noreply.github.com>
|
||||
Frankie Robertson <frankier@users.noreply.github.com>
|
||||
Gang Chen <goncha@gmail.com>
|
||||
Gavin Cai <gavin1818@hotmail.com>
|
||||
George Hindle <george@georgehindle.com>
|
||||
Georgi Gerganov <ggerganov@gmail.com>
|
||||
Gilad S <7817232+giladgd@users.noreply.github.com>
|
||||
Gilad S <giladgd@users.noreply.github.com>
|
||||
Gilad S. <7817232+giladgd@users.noreply.github.com>
|
||||
GitAritron <103900385+GitAritron@users.noreply.github.com>
|
||||
GiviMAD <GiviMAD@users.noreply.github.com>
|
||||
Gleicon Moraes <gleicon@gmail.com>
|
||||
Gregor Jasny <gjasny@googlemail.com>
|
||||
Guillaume Wenzek <gwenzek@users.noreply.github.com>
|
||||
HY. Kelvin Lee <34256578+hykelvinlee42@users.noreply.github.com>
|
||||
Halalaluyafail3 <55773281+Halalaluyafail3@users.noreply.github.com>
|
||||
Hang <bebound@gmail.com>
|
||||
Haus1 <haus.xda@gmail.com>
|
||||
Herman Semenov <GermanAizek@yandex.ru>
|
||||
HimariO <dsfhe49854@gmail.com>
|
||||
Hong Bo PENG <penghb@cn.ibm.com>
|
||||
Hrishikesh Barman <geekodour@users.noreply.github.com>
|
||||
Hugo <hugo@whynothugo.nl>
|
||||
Ian Bicking <ian@ianbicking.org>
|
||||
Ian Bull <irbull@eclipsesource.com>
|
||||
Ihar Hrachyshka <ihrachys@redhat.com>
|
||||
Ikko Ashimine <eltociear@gmail.com>
|
||||
Ikko Eltociear Ashimine <eltociear@gmail.com>
|
||||
InconsolableCellist <23345188+InconsolableCellist@users.noreply.github.com>
|
||||
Ismatulla Mansurov <47342870+sapoepsilon@users.noreply.github.com>
|
||||
Ivan <nekotekina@gmail.com>
|
||||
Ivan Filipov <159561759+vanaka11@users.noreply.github.com>
|
||||
Ivan Gorin <ivangorin21@gmail.com>
|
||||
Ivo von Putzer Reibegg <ivo.putzer@gmail.com>
|
||||
JJ <103335846+computerscienceiscool@users.noreply.github.com>
|
||||
Jack Mousseau <jmousseau@users.noreply.github.com>
|
||||
JacobLinCool <jacoblincool@gmail.com>
|
||||
Jakub Ráček <blizzcz@gmail.com>
|
||||
Jared Van Bortel <jared@nomic.ai>
|
||||
Jay Binks <jaybinks@gmail.com>
|
||||
Jayant <jayantyadav202@gmail.com>
|
||||
Jeff Bolz <jbolz@nvidia.com>
|
||||
Jeroen Mostert <jeroen.mostert@cm.com>
|
||||
Jhen-Jie Hong <developer@jhen.me>
|
||||
Jhen-Jie Hong <iainst0409@gmail.com>
|
||||
JidongZhang-THU <1119708529@qq.com>
|
||||
Jo Liss <joliss42@gmail.com>
|
||||
Joe Todd <joe.todd@codeplay.com>
|
||||
Johan <jr.raffin@gmail.com>
|
||||
Johannes Gäßler <johannesg@5d6.de>
|
||||
John Balis <phobossystems@gmail.com>
|
||||
JohnnyB <jboero@users.noreply.github.com>
|
||||
Jonathan Soo <jcsoo@agora.com>
|
||||
Jonno <1160532+razodactyl@users.noreply.github.com>
|
||||
Joonas Pihlajamaa <joonas.pihlajamaa@iki.fi>
|
||||
Jose <34888496+Jerry-Master@users.noreply.github.com>
|
||||
Josh Bleecher Snyder <josharian@gmail.com>
|
||||
Josscii <jossciiweiyi@gmail.com>
|
||||
Judd <foldl@users.noreply.github.com>
|
||||
Jumper775 <78500318+jumpers775@users.noreply.github.com>
|
||||
Jun Hee Yoo <contact.jhyoo@gmail.com>
|
||||
Junil Kim <logyourself@gmail.com>
|
||||
Justina Cho <justcho5@gmail.com>
|
||||
Justine Tunney <jtunney@gmail.com>
|
||||
Justine Tunney <jtunney@mozilla.com>
|
||||
KITAITI Makoto <KitaitiMakoto@gmail.com>
|
||||
KP Kaiser <kirk@zothcorp.com>
|
||||
Kamilake <exjang0@gmail.com>
|
||||
Karol Kontny <82021046+kkontny@users.noreply.github.com>
|
||||
Karthick <j.karthic2004@gmail.com>
|
||||
Kartik Saranathan <278928+Kartiku@users.noreply.github.com>
|
||||
Kasumi <90275229+kasumi-1@users.noreply.github.com>
|
||||
Kawrakow <48489457+ikawrakow@users.noreply.github.com>
|
||||
Kendrick Taylor <kendrick@circuitsix.com>
|
||||
Kevin Brothaler <admin@digipom.com>
|
||||
Kevin Gibbons <bakkot@gmail.com>
|
||||
Konosuke Sakai <konosuke@konosuke.work>
|
||||
Konstantin Zhuravlyov <konstantin.zhuravlyov@amd.com>
|
||||
Kreijstal <rainb@tfwno.gf>
|
||||
Kylin <56434533+KyL0N@users.noreply.github.com>
|
||||
LBlue <153975653+lbluep@users.noreply.github.com>
|
||||
Larry Battle <larry.battle.tech@gmail.com>
|
||||
Laytan Laats <laytanlaats@hotmail.com>
|
||||
Leo Moll <leo.moll@yeasoft.com>
|
||||
Lexevolution <31176843+Lexevolution@users.noreply.github.com>
|
||||
LittleLoli <26589867+WhichWho@users.noreply.github.com>
|
||||
Lucas Zanek <57494138+LucasZNK@users.noreply.github.com>
|
||||
Luis Herrera <herrera-luis@users.noreply.github.com>
|
||||
Lukas Rist <glaslos@gmail.com>
|
||||
M. A. Ali <73258591+MightyStud@users.noreply.github.com>
|
||||
M. Eren Akbiyik <erenakbiyik@gmail.com>
|
||||
Ma Mingfei <mingfei.ma@intel.com>
|
||||
Maciek <maciek.mab122@gmail.com>
|
||||
Mahesh Madhav <67384846+heshpdx@users.noreply.github.com>
|
||||
Marcin Mielniczuk <marmistrz.dev@zoho.eu>
|
||||
Mark Karpelès <MagicalTux@users.noreply.github.com>
|
||||
Mark Zhuang <zhuangqiubin@gmail.com>
|
||||
Markus Tavenrath <mtavenrath@users.noreply.github.com>
|
||||
Martin Delille <martin@delille.org>
|
||||
Martin Warnaar <martinwarnaar@gmail.com>
|
||||
Masaya, Kato <62578291+msy-kato@users.noreply.github.com>
|
||||
Matheus de Sousa <23645013+keyehzy@users.noreply.github.com>
|
||||
Mathieu Baudier <mbaudier@argeo.org>
|
||||
Mathijs de Bruin <mathijs@mathijsfietst.nl>
|
||||
Matija Pevec <mightymatth@users.noreply.github.com>
|
||||
Matt Stephenson <mstephenson6@users.noreply.github.com>
|
||||
Max Krasnyansky <max.krasnyansky@gmail.com>
|
||||
Max Krasnyansky <quic_maxk@quicinc.com>
|
||||
Maximiliano Levi <8160966+maxilevi@users.noreply.github.com>
|
||||
Meng, Hengyu <hengyu.meng@intel.com>
|
||||
Mengqing Cao <cmq0113@163.com>
|
||||
Michael Podvitskiy <podvitskiymichael@gmail.com>
|
||||
Michael Rienstra <mrienstra@gmail.com>
|
||||
Mikhail Grigorev <sleuthhound@gmail.com>
|
||||
Mohammadreza Hendiani <hendiani.mohammadreza@gmail.com>
|
||||
Mohit Agarwal <mohit@sdf.org>
|
||||
Molly Sophia <mollysophia379@gmail.com>
|
||||
Murilo Santana <mvrilo@gmail.com>
|
||||
NETZkultur GmbH <mulholland@netzkultur.de>
|
||||
Natsu <chino@hotococoa.moe>
|
||||
Neil Chudleigh <nchudleigh@users.noreply.github.com>
|
||||
Neo Zhang <14088817+arthw@users.noreply.github.com>
|
||||
Neo Zhang Jianyu <jianyu.zhang@intel.com>
|
||||
Neuman Vong <neuman.vong@gmail.com>
|
||||
Nicholai Tukanov <nicholaitukanov@gmail.com>
|
||||
Nicholas Albion <nalbion@yahoo.com>
|
||||
Nico Bosshard <nico@bosshome.ch>
|
||||
Nicolò Scipione <nicolo.scipione@codeplay.com>
|
||||
Niels Mayer <Niels.Mayer@gmail.com>
|
||||
Nikita Sarychev <42014488+sARY77@users.noreply.github.com>
|
||||
Nikolaj Olsson <nikse.dk@gmail.com>
|
||||
Okabintaro <103938900+Okabintaro@users.noreply.github.com>
|
||||
Oleg Sidorov <me@whitebox.io>
|
||||
Oleg Sidorov <oleg@sidorov.nl>
|
||||
Olivier Chafik <ochafik@users.noreply.github.com>
|
||||
Ondrej Kokes <ondrej.kokes@gmail.com>
|
||||
Ouadie EL FAROUKI <ouadie.elfarouki@codeplay.com>
|
||||
PAB <pierreantoine.bannier@gmail.com>
|
||||
Paul Tsochantaris <ptsochantaris@icloud.com>
|
||||
Pedro Probst <pprobst@insiberia.net>
|
||||
Peng <hzp1024@qq.com>
|
||||
Peter <peter277@users.noreply.github.com>
|
||||
Philipp Zabel <philipp.zabel@gmail.com>
|
||||
Philippe Normand <phil@base-art.net>
|
||||
Philippe Normand <philn@igalia.com>
|
||||
Plamen Minev <pacominev@gmail.com>
|
||||
Prashant Vithule <119530321+Vithulep@users.noreply.github.com>
|
||||
Przemysław Pawełczyk <przemoc@gmail.com>
|
||||
Qianhe Chen <54462604+chenqianhe@users.noreply.github.com>
|
||||
R0CKSTAR <xiaodong.ye@mthreads.com>
|
||||
R0CKSTAR <yeahdongcn@gmail.com>
|
||||
Radoslav Gerganov <rgerganov@gmail.com>
|
||||
Radosław Gryta <radek.gryta@gmail.com>
|
||||
Rahul Vadhyar <107788610+RahulVadhyar@users.noreply.github.com>
|
||||
Raiya Araki <83504221+rai62@users.noreply.github.com>
|
||||
Reinforce-II <fate@eastal.com>
|
||||
Reinis Muiznieks <muiznieks.reinis@gmail.com>
|
||||
RelatedTitle <r3latedtitle@gmail.com>
|
||||
Rémy Oudompheng <oudomphe@phare.normalesup.org>
|
||||
RhinoDevel <RhinoDevel@users.noreply.github.com>
|
||||
Rich Jones <miserlou@gmail.com>
|
||||
Robert Ormandi <52251610+ormandi@users.noreply.github.com>
|
||||
Robin <robin.xw@hotmail.com>
|
||||
Roddur Dasgupta <roddurd@gmail.com>
|
||||
Roland Rabien <figbug@gmail.com>
|
||||
Romain Biessy <romain.biessy@codeplay.com>
|
||||
Ronsor <ronsor@ronsor.pw>
|
||||
Rotem Dan <rotemdan@gmail.com>
|
||||
Ryan Hitchman <hitchmanr@gmail.com>
|
||||
Ryan Metcalfe <107415876+RyanMetcalfeInt8@users.noreply.github.com>
|
||||
RyanChang <ftes90015@gmail.com>
|
||||
SRHMorris <69468379+SRHMorris@users.noreply.github.com>
|
||||
SXX <sxx1136965276@gmail.com>
|
||||
Sacha Arbonel <sacha.arbonel@hotmail.fr>
|
||||
Salman Faroz <stsfaroz@gmail.com>
|
||||
Salvatore Mesoraca <s.mesoraca16@gmail.com>
|
||||
Sam <49637763+Onlyartist9@users.noreply.github.com>
|
||||
Sam Pullara <spullara@gmail.com>
|
||||
Samuel Durante <44513615+samueldurantes@users.noreply.github.com>
|
||||
Sanchit Gandhi <93869735+sanchit-gandhi@users.noreply.github.com>
|
||||
Sandro Hanea <40202887+sandrohanea@users.noreply.github.com>
|
||||
Sergio López <slp@redhat.com>
|
||||
Sergio López <slp@sinrega.org>
|
||||
Shanshan Shen <467638484@qq.com>
|
||||
Shijie <821898965@qq.com>
|
||||
Shupei Fan <dymarkfan@outlook.com>
|
||||
Siddharth Ramakrishnan <srr2141@columbia.edu>
|
||||
Sigbjørn Skjæret <sigbjorn.skjaeret@scala.com>
|
||||
Simon Moisselin <simon.moisstoll@gmail.com>
|
||||
Sindre Sorhus <sindresorhus@gmail.com>
|
||||
Slava Primenko <primenko.s@gmail.com>
|
||||
Srihari-mcw <96763064+Srihari-mcw@users.noreply.github.com>
|
||||
Stavros Panakakis <53979866+Stavrospanakakis@users.noreply.github.com>
|
||||
Stefan Sydow <s.sydow@heinlein-video.de>
|
||||
Stefan Sydow <stefan@sydow.email>
|
||||
Syahmi Azhar <prsyahmi@gmail.com>
|
||||
Syed Jafri <syedjafri97@gmail.com>
|
||||
Sơn Phan Trung <phantrungson17@gmail.com>
|
||||
Taisei Mima <bhbstar.me@gmail.com>
|
||||
Takeshi Inoue <inoue.takeshi@gmail.com>
|
||||
Tamotsu Takahashi <ttakah+github@gmail.com>
|
||||
Taras Glek <taras@thegp.com>
|
||||
Tauseef Mohiuddin <35351464+tauseefmohammed2@users.noreply.github.com>
|
||||
Thamster <Thamster@users.noreply.github.com>
|
||||
Thijs Raymakers <thijs@raymakers.nl>
|
||||
Thomas Fitzsimmons <fitzsim@fitzsim.org>
|
||||
Tiago Fassoni <tiagofassoni@users.noreply.github.com>
|
||||
Tienshiao Ma <tienshiao@tienshiao.org>
|
||||
Tim Miller <drasticactions@users.noreply.github.com>
|
||||
Timothy Cronin <40186632+4imothy@users.noreply.github.com>
|
||||
Tobrun <tobrun.van.nuland@gmail.com>
|
||||
Todd <taf2@users.noreply.github.com>
|
||||
Toliver <teejae@gmail.com>
|
||||
Tong Li <31761981+litongjava@users.noreply.github.com>
|
||||
Tony Wasserka <4840017+neobrain@users.noreply.github.com>
|
||||
Topping1 <78745143+Topping1@users.noreply.github.com>
|
||||
Travis Cline <travis.cline@gmail.com>
|
||||
UEXTM.com <84163508+uextm@users.noreply.github.com>
|
||||
UsernamesLame <156965854+UsernamesLame@users.noreply.github.com>
|
||||
Vadim Peretokin <vperetokin@hey.com>
|
||||
Valentin Gosu <1454649+valenting@users.noreply.github.com>
|
||||
Vin Misra <vinith@alum.mit.edu>
|
||||
Vulcan <93451215+trholding@users.noreply.github.com>
|
||||
WhiteOlivierus <36532695+WhiteOlivierus@users.noreply.github.com>
|
||||
William Tambellini <william.tambellini@gmail.com>
|
||||
William Tambellini <wtambellini@sdl.com>
|
||||
Wilson Silva <wilson.dsigns@gmail.com>
|
||||
Xiang (Kevin) Li <kevinli020508@gmail.com>
|
||||
Xiao-Yong Jin <jinxiaoyong@gmail.com>
|
||||
XiaotaoChen <chenxiaotao1234@gmail.com>
|
||||
Xingchen Song(宋星辰) <xingchensong1996@163.com>
|
||||
Xinpeng Dou <81913537+Dou-Git@users.noreply.github.com>
|
||||
Xuan Son Nguyen <thichthat@gmail.com>
|
||||
Yajing Tang <phillis@google.com>
|
||||
Yang Shen <aplshenyang@gmail.com>
|
||||
Yunès <jean.baptiste.yunes@free.fr>
|
||||
Yuri Khrustalev <ykhrustalev@users.noreply.github.com>
|
||||
Yusuf Redžić <48274562+redzic@users.noreply.github.com>
|
||||
ZaBlazzingZephyrus <119159668+blazingzephyr@users.noreply.github.com>
|
||||
Zhenwei Jin <109658203+kylo5aby@users.noreply.github.com>
|
||||
Zhiyuan Li <lizhiyuan@uniartisan.com>
|
||||
Zhiyuan Li <uniartisan2017@gmail.com>
|
||||
Zigfrid Zvezdin <ziggerZZ@gmail.com>
|
||||
Zollner <24618122+Zolliner@users.noreply.github.com>
|
||||
a3sh <38979186+A3shTnT@users.noreply.github.com>
|
||||
ag2s20150909 <19373730+ag2s20150909@users.noreply.github.com>
|
||||
agray3 <agray3@users.noreply.github.com>
|
||||
ai-at-home <149282006+ai-at-home@users.noreply.github.com>
|
||||
aldorof <aldorof@users.noreply.github.com>
|
||||
alonfaraj <alonfaraj@gmail.com>
|
||||
amd-dwang <dong.wang@amd.com>
|
||||
amritahs-ibm <amritahs@linux.vnet.ibm.com>
|
||||
andypayne <apayne@gmail.com>
|
||||
ardfork <134447697+ardfork@users.noreply.github.com>
|
||||
arizhih <40765267+arizhih@users.noreply.github.com>
|
||||
automaticcat <daogiatuank54@gmail.com>
|
||||
bandoti <141645996+bandoti@users.noreply.github.com>
|
||||
be-next <jerome.ramette@gmail.com>
|
||||
bert hubert <bert@hubertnet.nl>
|
||||
billyct <billy_allen@126.com>
|
||||
bmwl <brian.marshall@tolko.com>
|
||||
bobqianic <129547291+bobqianic@users.noreply.github.com>
|
||||
bocytko <bocytko+github@gmail.com>
|
||||
boolemancer <48014766+boolemancer@users.noreply.github.com>
|
||||
boolemancer <boolemancer@gmail.com>
|
||||
bradmit <151883577+bradmit@users.noreply.github.com>
|
||||
brunofaustino <b.fa.amorim@gmail.com>
|
||||
bssrdf <merlintiger@hotmail.com>
|
||||
byte-6174 <88070277+byte-6174@users.noreply.github.com>
|
||||
cdosoftei <ciprian.dosoftei@gmail.com>
|
||||
clach04 <Chris.Clark@actian.com>
|
||||
compilade <113953597+compilade@users.noreply.github.com>
|
||||
compilade <git@compilade.net>
|
||||
conradg <conradjgodfrey@gmail.com>
|
||||
crummyh <elijah@crums.us>
|
||||
ddpasa <112642920+ddpasa@users.noreply.github.com>
|
||||
denersc <denerstassun@gmail.com>
|
||||
dscripka <dscripka@users.noreply.github.com>
|
||||
duthils <duthils@duthils.net>
|
||||
ecneladis <ecneladis@users.noreply.github.com>
|
||||
faker <nspyia2002@gmail.com>
|
||||
fitzsim <fitzsim@fitzsim.org>
|
||||
fj-y-saito <85871716+fj-y-saito@users.noreply.github.com>
|
||||
fraxy-v <65565042+fraxy-v@users.noreply.github.com>
|
||||
genevera (she/her) <genevera@users.noreply.github.com>
|
||||
geniusnut <geniusnut@gmail.com>
|
||||
gilbertgong <gilbert.gong@gmail.com>
|
||||
gn64 <yukikaze.jp@gmail.com>
|
||||
goldwaving <77494627+goldwaving@users.noreply.github.com>
|
||||
greeshmay <greeshmay@gmail.com>
|
||||
haopeng <657407891@qq.com>
|
||||
hipudding <huafengchun@gmail.com>
|
||||
hsinhoyeh <yhh92u@gmail.com>
|
||||
hydai <z54981220@gmail.com>
|
||||
iamthad <thadeus.j.fleming@gmail.com>
|
||||
issixx <46835150+issixx@users.noreply.github.com>
|
||||
james wolf <contractorwolf@hotmail.com>
|
||||
jdomke <28772296+jdomke@users.noreply.github.com>
|
||||
jettoblack <jettoblack@gmail.com>
|
||||
jiez <373447296@qq.com>
|
||||
joecryptotoo <80373433+joecryptotoo@users.noreply.github.com>
|
||||
jorismertz <35079666+jorismertz@users.noreply.github.com>
|
||||
junchao-loongson <68935141+junchao-loongson@users.noreply.github.com>
|
||||
junkfood <69683722+JunkFood02@users.noreply.github.com>
|
||||
jwijffels <jwijffels@bnosac.be>
|
||||
k.h.lai <adrian.k.h.lai@outlook.com>
|
||||
kamranjon <kamranjon@gmail.com>
|
||||
katsu560 <katsu560oo-@docomo.ne.jp>
|
||||
kennethge <57784063+kenneth-ge@users.noreply.github.com>
|
||||
keyehzy <msamuel@aluno.puc-rio.br>
|
||||
kunnis <kunnis@users.noreply.github.com>
|
||||
l3utterfly <gc.pthzfoldr@gmail.com>
|
||||
leejet <leejet714@gmail.com>
|
||||
leo-pony <nengjunma@outlook.com>
|
||||
lhez <quic_lih@quicinc.com>
|
||||
litong <31761981+litongjava@users.noreply.github.com>
|
||||
liuwei-git <14815172+liuwei-git@users.noreply.github.com>
|
||||
lnyan <lkwq007@gmail.com>
|
||||
luoyu-intel <yu.luo@intel.com>
|
||||
m.bell <m.bell@techsmith.com>
|
||||
mahorozte <41834471+mahorozte@users.noreply.github.com>
|
||||
mashizora <30516315+mashizora@users.noreply.github.com>
|
||||
matt23654 <matthew.webber@protonmail.com>
|
||||
matteo <matteogeniaccio@yahoo.it>
|
||||
mgrachten <maarten@grachten.eu>
|
||||
mkiol <mkiol@users.noreply.github.com>
|
||||
mky_coder <47767389+mkycoder@users.noreply.github.com>
|
||||
novag <7754358+novag@users.noreply.github.com>
|
||||
pajowu <pajowu@pajowu.de>
|
||||
pengxin99 <pengxin.yuan@intel.com>
|
||||
petterreinholdtsen <pere-github@hungry.com>
|
||||
polarmoon <90010972+polarmoon@users.noreply.github.com>
|
||||
rlapray <lapray.romain@gmail.com>
|
||||
sandrohanea <40202887+sandrohanea@users.noreply.github.com>
|
||||
semiformal-net <84111142+semiformal-net@users.noreply.github.com>
|
||||
shibukazu <61775791+shibukazu@users.noreply.github.com>
|
||||
shikokuchuo <53399081+shikokuchuo@users.noreply.github.com>
|
||||
slaren <slarengh@gmail.com>
|
||||
slashlib <slashlib@users.noreply.github.com>
|
||||
snadampal <87143774+snadampal@users.noreply.github.com>
|
||||
someone13574 <81528246+someone13574@users.noreply.github.com>
|
||||
st-gr <38470677+st-gr@users.noreply.github.com>
|
||||
stduhpf <stephduh@live.fr>
|
||||
stormofice <58337328+stormofice@users.noreply.github.com>
|
||||
texmex76 <40733439+texmex76@users.noreply.github.com>
|
||||
thefinaldegree <thefinaldegree@gmail.com>
|
||||
thewh1teagle <61390950+thewh1teagle@users.noreply.github.com>
|
||||
toboil-features <160222185+toboil-features@users.noreply.github.com>
|
||||
trixirt <trix@redhat.com>
|
||||
ulatekh <ulatekh@yahoo.com>
|
||||
undef <undefdev@gmail.com>
|
||||
uvos <devnull@uvos.xyz>
|
||||
uvos <philipp@uvos.xyz>
|
||||
valVk <valVk@users.noreply.github.com>
|
||||
venkr <venkateshrameshkumar+1@gmail.com>
|
||||
vicalloy <zbirder@gmail.com>
|
||||
wangshuai09 <391746016@qq.com>
|
||||
woachk <24752637+woachk@users.noreply.github.com>
|
||||
xctan <axunlei@gmail.com>
|
||||
xdrudis <xavierdrudis@yahoo.es>
|
||||
yuri@FreeBSD <yuri@FreeBSD>
|
||||
zhangjixiong <code.zjx@gmail.com>
|
||||
zhentaoyu <zhentao.yu@intel.com>
|
||||
zhouwg <6889919+zhouwg@users.noreply.github.com>
|
||||
zhouwg <zhouwg2000@gmail.com>
|
||||
谢乃闻 <sienaiwun@users.noreply.github.com>
|
||||
布客飞龙 <562826179@qq.com>
|
||||
Артём Земляк <azemlyak@smart-consulting.ru>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user