1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-27 20:29:45 +02:00

Compare commits

...

120 Commits

Author SHA1 Message Date
Laurent Cozic
d7d6fd5ccd Android 3.3.3 2025-03-16 10:49:01 +00:00
Laurent Cozic
23254e6ffd Desktop release v3.3.3 2025-03-16 10:25:28 +00:00
Meow
eb8bfd5aec iOS: Re-Add iOS Dark Icon (#11943)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-03-16 10:21:58 +00:00
Laurent Cozic
cb5ffd968d Desktop: Add support for multiple instances (#11963) 2025-03-16 10:18:32 +00:00
Henry Heino
7b2b3a4f80 Chore: Increase Playwright test timeouts and reduce test flakiness (#11970) 2025-03-15 23:50:53 +00:00
Henry Heino
cbfe109c41 iOS: Accessibility: Fix focus gets stuck on "Attach" in the note actions menu (#11958) 2025-03-15 13:20:23 +00:00
Henry Heino
c8b01d11d6 Mobile: Accessibility: Make default modal close button accessible (#11957) 2025-03-15 13:20:11 +00:00
Henry Heino
b042395fd1 Web: Accessibility: Fix "sort notes by" button is sometimes not keyboard focusable (#11959) 2025-03-15 13:20:01 +00:00
Henry Heino
ba5ad18093 Desktop: Accessibility: Add a menu item that moves focus to the note viewer (#11967) 2025-03-15 13:19:47 +00:00
Henry Heino
ff15232a10 Android: Resolves #11956: Voice typing: Transcribe more unprocessed audio after pressing "done" (#11960) 2025-03-15 12:29:05 +00:00
Henry Heino
5a6e72197a Desktop: Upgrade to Electron 35.0.1 (#11968) 2025-03-15 12:01:18 +00:00
summoner001
de555b6871 All: Translation: Update hu_HU.po (#11962) 2025-03-14 13:00:27 -04:00
cro
9a2548a5e3 Update webdav.md (#11951) 2025-03-14 00:15:51 +00:00
Henry Heino
107996289f Mobile: Accessibility: Fix missing label on note actions menu dismiss button (#11954) 2025-03-13 19:56:19 +00:00
Henry Heino
c3c0101555 Android: Voice typing: Fix potential output duplication when finalizing voice typing (#11953) 2025-03-13 19:14:06 +00:00
Laurent Cozic
64f3dae8cc Doc: Fixed sponsor "alt" tag on website main page 2025-03-12 21:03:20 +00:00
Henry Heino
a39b51cc97 Docs: Fix website build (#11947) 2025-03-10 16:40:35 +00:00
Henry Heino
10bb8ef1a9 Docs: Resolves #11860: Add guidelines for making new contributions accessible (#11863) 2025-03-08 12:12:00 +00:00
Laurent Cozic
60ba22b233 Doc: Fix "How to" documents 2025-03-08 12:09:37 +00:00
Henry Heino
1bfd997be2 Docs: Accessibility: Document how to use the app with a screen reader (#11897) 2025-03-08 11:55:36 +00:00
Henry Heino
81e4a7fb74 Desktop: Fix adding tags to a note through drag-and-drop (#11911) 2025-03-08 11:54:24 +00:00
Henry Heino
360568d325 Desktop: Fixes #11894: Fix ctrl-p doesn't open the goto anything dialog in the Rich Text Editor (#11926) 2025-03-08 11:54:12 +00:00
Henry Heino
1aa0f11670 Mobile: Accessibility: Improve focus handling in the note actions menu and modal dialogs (#11929) 2025-03-08 11:53:06 +00:00
Henry Heino
0430ccb3e7 iOS: Accessibility: Fix plugins can't be installed using VoiceOver (#11931) 2025-03-08 11:52:03 +00:00
av
c0d6c1eb0b Tools: add giflib to devbox dependencies (#11938) 2025-03-08 11:51:48 +00:00
Amine Zouaoui
215f09d73c Desktop: Resolves #11696: Add "Disable synchronisation" to Joplin Cloud prompt message (#11705) 2025-03-08 11:50:30 +00:00
pedr
1f192696de Desktop: Fixes #11939: Import audio from OneNote as file links (#11942) 2025-03-08 11:49:20 +00:00
Laurent Cozic
ab86b95fad Desktop, Mobile, Cli: Add setting migration for ocr.enabled 2025-03-07 15:47:44 +00:00
Laurent Cozic
0f07c0f53a Desktop, Mobile: Fixes #11673: Make tab size consistent between Markdown editor and viewer (and RTE) (#11940)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2025-03-07 15:42:32 +00:00
Dmitriy Q
a6d04c4781 All: Translation: Update ru_RU.po (#11937) 2025-03-06 23:44:58 -05:00
PARAMESH T S
bc27f47881 Desktop: Fixes #11923: Sharing a notebook with nobody prints "No user with ID public_key" (#11932)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-03-06 15:58:10 +00:00
Henry Heino
d1d75449f5 Chore: CI: Upgrade Linux actions runner to Ubuntu 22.04 (#11927) 2025-03-06 00:19:57 +00:00
Laurent Cozic
bbea5388ed Doc: Describe how to migrate from Joplin Cloud Basic or Pro to Team 2025-03-05 19:42:47 +00:00
Laurent Cozic
99e773855e Chore: Improve error message when website does not build 2025-03-05 18:57:02 +00:00
Laurent Cozic
55b73347e5 Doc: Fixed downloading Apple Silicon version on Download page 2025-03-05 18:56:44 +00:00
Laurent Cozic
7e8dee4906 Desktop: Added keyboard shortcut and menu item for toggleEditorPlugin command 2025-03-05 00:43:39 +00:00
Laurent Cozic
69fb1ab104 Chore: Fixed test that fails on fast enough computers 2025-03-05 00:43:39 +00:00
Helmut K. C. Tessarek
67ae0ea2d1 Desktop: improve download in install script (#11921) 2025-03-04 19:06:31 -05:00
Celestial.y
cdb61b922b All: Translation: Update zh_CN.po (#11922) 2025-03-04 18:52:12 -05:00
pedr
da80443796 Chore: Remove file created during automated test (#11915) 2025-03-04 11:58:57 +00:00
Henry Heino
1924dd31d2 Desktop: Make "toggle all folders" button also expand the folder list (#11917) 2025-03-04 11:58:31 +00:00
Henry Heino
b831d8c068 Desktop: Accessibility: Improve "toggle all notebooks" accessibility (#11918) 2025-03-04 11:57:11 +00:00
Joplin Bot
4ad1b49769 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-03-04 02:03:55 +00:00
klxiang
0d6c1067e3 All: Translation: Update zh_CN.po (#11920) 2025-03-03 21:01:55 -05:00
Eric Duarte
0bdc38a6be All: Translation: Update es_ES.po (#11913) 2025-03-03 20:43:27 -05:00
Laurent Cozic
5c35569b5b Android 3.3.2 2025-03-03 22:36:37 +00:00
Laurent Cozic
5f02af9724 Server v3.3.4 2025-03-03 22:29:46 +00:00
Henry Heino
975f16d21c Server: Security: Improve request validation in default route (#11916) 2025-03-03 22:29:05 +00:00
Laurent Cozic
06359834d6 Desktop: Add a button to collapse or expand all folders (#11905) 2025-03-02 22:20:47 +00:00
Laurent Cozic
0cc0fec8c3 lock file 2025-03-01 13:04:37 +00:00
Joplin Bot
68ab5dcda5 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-03-01 02:08:23 +00:00
Joplin Bot
65544123e6 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-02-28 18:43:59 +00:00
Laurent Cozic
cfbded00e2 Merge branch 'release-3.2' into dev 2025-02-28 14:14:21 +00:00
Laurent Cozic
a898e17b4c Desktop release v3.2.13 2025-02-28 14:13:20 +00:00
pedr
d12e2d9a81 Desktop: Fixes #11759: Preserve attachment file extensions regardless of the mime type (#11852)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-02-28 14:13:08 +00:00
Henry Heino
7025321d76 Mobile: Accessibility: Fix "new note" and "new to-do" buttons are focusable even while invisible (#11899) 2025-02-28 10:30:50 +00:00
Laurent Cozic
6c890121b9 Doc: Update release cycle 2025-02-27 18:47:02 +00:00
Josh Scheitler
9c4be00745 Desktop: Resolves #11663: Improve Rich Text Editor toolbar structure (#11869)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-02-27 18:32:47 +00:00
Henry Heino
7f51712311 Android: Switch default library used for Whisper voice typing (#11881) 2025-02-27 18:31:13 +00:00
Anmol Garg
502c929c88 Chore: Update Docker Compose POSTGRES_HOST for proper service-to-service communication (#11886) 2025-02-27 18:26:21 +00:00
Kev Bittner
1abf9e9602 Doc: Update S3 synchronization documentation (#11890) 2025-02-27 18:24:45 +00:00
Laurent Cozic
8bdb6c5d72 Desktop: Add dialog to select a note and link to it (#11891) 2025-02-27 18:24:02 +00:00
Henry Heino
9cbd1b855c Desktop: Accessibility: Add more standard keyboard shortcuts for the notebook sidebar (#11892) 2025-02-27 18:23:28 +00:00
Laurent Cozic
ae8658554f Desktop: Fix issue with GotoAnything that would prevent it from highlighting search results in note titles (#11888) 2025-02-27 16:29:33 +00:00
renovate[bot]
bc385d59e9 Update dependency @types/node to v18.19.67 (#11880)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-27 16:13:24 +00:00
renovate[bot]
00ccd994e3 Update dependency @types/adm-zip to v0.5.7 (#11895)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-27 16:11:10 +00:00
Laurent Cozic
9251299289 Deskop: Attempt to capture more debug info when the app crashes 2025-02-26 10:51:18 +00:00
Laurent Cozic
fe67a44285 Plugins: Add support for joplin.shouldUseDarkColors API 2025-02-25 15:33:44 +00:00
Laurent Cozic
50a1b184fd Chore: Desktop: Ensure dev tools are open on startup in dev mode 2025-02-24 16:55:52 +00:00
Laurent Cozic
3caa718132 Chore: Improve error message when renderMarkup command cannot render some text 2025-02-24 16:54:57 +00:00
Laurent Cozic
d0e16c0878 Chore: Improve error message when data API cannot parse a note 2025-02-24 16:54:56 +00:00
renovate[bot]
4fcb250c27 Update dependency @bam.tech/react-native-image-resizer to v3.0.11 (#11879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 00:01:48 +00:00
Laurent Cozic
86e59ad621 Server v3.3.3 2025-02-23 19:07:47 +00:00
Laurent Cozic
12baa9827d Server: Fixed patching user properties 2025-02-23 18:40:12 +00:00
pedr
95c50ada7c Mobile: Fixes #11858: Fix disabled encryption keys list showing enabled keys (#11861) 2025-02-23 14:08:55 +00:00
Henry Heino
55a57f7baf Mobile: Resolves #11846: Improve encryption config screen accessibility (#11874) 2025-02-23 14:08:09 +00:00
summoner001
69b24b4437 Update hu-HU.po (#11877) 2025-02-23 13:56:11 +00:00
Henry Heino
5143fae0f6 Mobile: Fixes #11864: Fix voice recorder crash (#11876) 2025-02-23 13:53:28 +00:00
Henry Heino
01a62acfdf Chore: Fix yarn tsc fails when run from packages/utils (#11873) 2025-02-23 13:52:24 +00:00
renovate[bot]
c663742689 Update dependency @types/node to v18.19.65 (#11875)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-23 11:42:29 +00:00
Laurent Cozic
0c405951ed Server v3.3.2 2025-02-19 23:16:30 +00:00
Laurent Cozic
4b411e600c Chore: Add iOS entitlements for push notifications 2025-02-19 21:58:43 +00:00
Laurent Cozic
bf58a52394 Chore: Server: Exclude db migration from test 2025-02-19 21:57:13 +00:00
Laurent Cozic
36d3736bff Server v3.3.1 2025-02-19 19:19:02 +00:00
Laurent Cozic
4df0b9f851 Server: Optimise delta sync queries by optimising the underlying SQL query 2025-02-19 19:01:09 +00:00
Joplin Bot
914b5e230d Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-02-19 18:42:49 +00:00
pedr
9278fd7910 Desktop: Accessibility: Add error indication on Note properties (#11784) 2025-02-19 18:32:29 +00:00
Laurent Cozic
2180ad1d9b iOS 13.3.1 2025-02-19 16:04:53 +00:00
Laurent Cozic
d301cdf992 Android 3.3.1 2025-02-19 16:03:59 +00:00
Laurent Cozic
200d3c84e0 Desktop release v3.3.2 2025-02-19 15:37:37 +00:00
Laurent Cozic
6cadaa2137 lock file 2025-02-19 15:37:20 +00:00
Henry Heino
8221081514 Mobile: Support attaching audio recordings (#11836) 2025-02-19 15:23:20 +00:00
Laurent Cozic
dd06b1e680 Desktop: Improve usability of note list when ticking to-dos using the Space key (#11855) 2025-02-19 15:19:20 +00:00
pedr
70e0ae0c2c Desktop: Fixes #11844: Fix OneNote importer not being able to handle corrupted attachments (#11859) 2025-02-19 15:18:53 +00:00
Laurent Cozic
7aeec923e3 Doc: Update OCR documentation 2025-02-19 14:33:01 +00:00
pedr
70d64225c8 Desktop: Accessibility: Make click outside of dialog content be cancellable (#11765) 2025-02-18 18:25:49 +00:00
Henry Heino
ad0ecc2320 Desktop: Fixes #11847: Hide extra clear button in search field (#11851) 2025-02-18 18:17:40 +00:00
pedr
8a28edcda8 Desktop: Fixes #11759: Preserve attachment file extensions regardless of the mime type (#11852)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-02-18 18:17:23 +00:00
Henry Heino
c8640aa7f8 Desktop: Fix Rich Text right-click and paste regressions (#11850) 2025-02-18 18:15:46 +00:00
Jozef Gaal
ddf75d6c52 New strings translated to Slovak (#11856) 2025-02-18 18:14:43 +00:00
Kamila Łopuszańska
0a42317e07 Doc: Resolves #11842: Updated faq.md (#11853) 2025-02-18 18:14:10 +00:00
Henry Heino
51ce1b06fe Chore: Docs: Document creating new editor commands (#11829) 2025-02-18 18:13:32 +00:00
Laurent Cozic
44c735afac Desktop: Improve behaviour of note list to-dos when ticking a checkbox using the keyboard 2025-02-17 22:38:43 +00:00
Laurent Cozic
c6154cfb4e Mobile: Add support for plugin editor views (#11831)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2025-02-17 13:47:56 +00:00
Joplin Bot
d2aad1d6c7 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-02-17 12:59:15 +00:00
Dan Dascalescu
3e81cc8585 Docs: update note tagging instructions in 1_welcome_to_joplin.md (#11834) 2025-02-17 12:11:45 +00:00
Henry Heino
abc5c062c3 iOS: Fix "attach file" doesn't work the first time after startup (#11839) 2025-02-17 12:09:10 +00:00
Henry Heino
316ef9d960 Desktop,Mobile: Plugins: Simplify getting the ID of the note open in an editor (#11841) 2025-02-17 12:08:48 +00:00
Henry Heino
b870f8344c iOS: Fixes #11835: Allow attaching videos to notes (#11840) 2025-02-17 12:07:15 +00:00
Joplin Bot
6f6683d15d Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-02-16 18:40:23 +00:00
Henry Heino
da59aef95b Desktop release v3.3.1 2025-02-16 08:36:01 -08:00
Laurent Cozic
c55979cd03 Desktop: Enable OCR processing by default 2025-02-16 16:15:51 +00:00
Laurent Cozic
07f4217f17 Chore: Fixed spelling issue 2025-02-13 10:30:20 +00:00
Laurent Cozic
8a7071179d Doc: Add "Area outside of Joplin's Threat Model" to Security.md 2025-02-13 09:55:10 +00:00
Joplin Bot
2c9a12307e Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-02-13 01:59:29 +00:00
renovate[bot]
dd3864fa47 Update dependency @adobe/css-tools to v4.4.1 (#11830)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-13 00:00:10 +00:00
Laurent Cozic
43c1c5849b Doc: Update sponsors 2025-02-12 22:57:30 +00:00
Sahil Rathore
5e08ff0621 Mobile: Fixes #11827: Canceling dev plugin path setup shows error (#11828)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-02-12 20:01:44 +00:00
balaraz
45838c0223 Update Ukrainian translation (#11824) 2025-02-12 08:55:29 +00:00
Henry Heino
17e463b6bc Desktop: Resolves #11710: Plugins: Mark the LanguageTool Integration plugin as incompatible (#11715) 2025-02-06 18:12:30 +00:00
813 changed files with 182441 additions and 5293 deletions

View File

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

View File

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

View File

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

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

View 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 && (

View File

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,8 @@
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "latest",
"git": "latest",
"giflib": "latest",
},
"shell": {
"init_hook": [

View File

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

View File

@@ -0,0 +1 @@
Додаток для заміток і завдань із синхронізацією між Linux, macOS, Windows і мобільними пристроями

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ const incompatiblePluginIds = [
'ylc395.noteLinkSystem',
'outline',
'joplin.plugin.cmoptions',
'com.asdibiase.joplin-languagetool',
// cSpell:enable
];

View File

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

View File

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

View File

@@ -163,6 +163,9 @@ const declarations: CommandDeclaration[] = [
{
name: 'editor.execCommand',
},
{
name: 'viewer.focus',
},
];
export default declarations;

View File

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

View File

@@ -10,6 +10,7 @@ const commandsWithDependencies = [
require('../commands/showLocalSearch'),
require('../commands/focusElementNoteTitle'),
require('../commands/focusElementNoteBody'),
require('../commands/focusElementNoteViewer'),
require('../commands/focusElementToolbar'),
require('../commands/pasteAsText'),
];

View File

@@ -118,6 +118,8 @@ const NoteList = (props: Props) => {
props.notes.length,
listRenderer.flow,
itemsPerLine,
props.showCompletedTodos,
props.uncompletedTodosOnTop,
);
useItemCss(listRenderer.itemCss);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 : [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,6 +33,10 @@ export const SearchInput = styled(StyledInput)`
padding-right: 20px;
flex: 1;
width: 10px;
&::-webkit-search-cancel-button {
display: none;
}
`;
interface Props {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
#pragma once
void findLongestSilence_test();

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

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View 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

View 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`.

View 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

View 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