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

Compare commits

...

56 Commits

Author SHA1 Message Date
Laurent Cozic
131ec9e913 Android 3.0.3 2024-04-27 12:27:53 +01:00
Laurent Cozic
e31ec031f4 Desktop release v3.0.4 2024-04-27 12:14:58 +01:00
Laurent Cozic
431ce430a0 Chore: Fixed sorting logic for search API 2024-04-27 12:11:40 +01:00
Henry Heino
5cdc1e93b3 Mobile: Fix quickly enabling/disabling multiple plugins can lead to errors and missing plugins (#10380) 2024-04-27 11:45:39 +01:00
Henry Heino
09216b8b59 Desktop: Fix "new notebook" shown in context menu when right-clicking on the "Tags" header (#10378) 2024-04-27 11:44:01 +01:00
Henry Heino
1bb3632a70 Mobile: Make editor styles closer to desktop (#10377) 2024-04-27 11:43:37 +01:00
Henry Heino
03617eb8a7 Mobile: Resolves #10360: Make most plugins default to being desktop-only (#10376) 2024-04-27 11:43:25 +01:00
Laurent Cozic
034e568d26 Desktop: Resolves #10334: Support URLs in plugin API imaging.createFromPath 2024-04-27 11:35:49 +01:00
Laurent Cozic
a0faca0997 Chore: Improve link detection function 2024-04-27 11:22:36 +01:00
Laurent Cozic
5268b5bf6b Desktop: Fixes #10283: Display correct sorting icon 2024-04-27 10:38:00 +01:00
Laurent Cozic
5b3f05f939 Chore: Fixed ancient typo 2024-04-27 10:21:00 +01:00
Laurent Cozic
b1a669de01 Desktop: Fixes #10274: old.reddit pages are not saved correctly as HTML by the Web Clipper 2024-04-27 10:19:35 +01:00
Laurent Cozic
a5f118bc26 All: Fixes #10189: After deleting the last note from the conflicts folder, the application state is invalid 2024-04-27 09:54:47 +01:00
Laurent Cozic
10978781cd Desktop: Fixes #10088: Search results from API change when fields param is used 2024-04-27 09:23:09 +01:00
Laurent Cozic
8bdec4c2b4 Desktop: Add context menu item to view OCR text of an attachment 2024-04-27 08:46:48 +01:00
Laurent Cozic
be58fced93 Desktop: Fixes #10056: Fixed error when processing certain PDF files for OCR 2024-04-27 08:45:52 +01:00
Laurent Cozic
c5dfa4c055 Desktop: Attach log to crash dump when the application crashes 2024-04-26 16:08:08 +01:00
Laurent Cozic
74bc9b36aa Desktop, Cli: Improved log formatting and allow saving last lines of log to memory 2024-04-26 16:08:06 +01:00
Henry Heino
993fbfb93f Mobile: Plugins: Fix API incompatibility in arguments to onMessage listeners in panels (#10375) 2024-04-25 23:04:24 +01:00
Henry Heino
97b5276f81 Desktop: Resolves #4251: Refactor sidebar to better handle thousands of tags and notebooks (#10331) 2024-04-25 15:31:18 +01:00
renovate[bot]
c6c7de286a Update dependency @types/node to v18.19.10 (#10373)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-25 13:07:53 +00:00
Henry Heino
aec77b543c Mobile: Plugin support: Simplify reporting plugin issues (#10319) 2024-04-25 14:02:10 +01:00
Henry Heino
34b265475d Mobile: Support copying app information (#10336) 2024-04-25 13:53:46 +01:00
pedr
4e95486c5c Clipper: Fix process stopping because one resource can't be created (#10337) 2024-04-25 13:53:34 +01:00
Akira Taguchi
296b60800a Doc: Create CONTRIBUTING (#10343)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2024-04-25 13:50:23 +01:00
Henry Heino
332e19ce64 Mobile: Fixes #10351: Fix sync icon off-center (#10350) 2024-04-25 13:41:28 +01:00
Henry Heino
8984243020 Android: Fixes #10352: Fix cursor jumps before end of word while typing -- update @codemirror/view to v6.26.3 (#10353) 2024-04-25 13:41:17 +01:00
Henry Heino
65c47189f9 Mobile,Desktop: Fixes #10191: Do not invite user to create new notes in the trash folder (#10356) 2024-04-25 13:34:32 +01:00
Henry Heino
6aca77a0ae Desktop: Fixes #10194: Fix note disappears while editing (#10370) 2024-04-25 13:34:11 +01:00
Henry Heino
0670ad92d7 Chore: Add no-throw-literal and prefer-promise-reject-errors eslint rules (#10371) 2024-04-25 13:32:37 +01:00
Archisman Panigrahi
bce71a00e9 Linux: Fixes #10354: Fixed Appimagelauncher support (#10355) 2024-04-25 13:32:24 +01:00
Henry Heino
83b50aaa8e Chore: Migrate file system sync to TypeScript (#10361) 2024-04-25 13:31:48 +01:00
github-actions[bot]
c7c4371902 @ifurther has signed the CLA in laurent22/joplin#10367 2024-04-24 14:07:34 +00:00
renovate[bot]
4978a473a1 Update dependency @adobe/css-tools to v4.3.3 (#10364)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-24 10:47:19 +00:00
Henry Heino
4d89d9f285 Docs: Fixes #10234: Mark Node 18 as the earliest supported version (#10362) 2024-04-24 11:46:00 +01:00
renovate[bot]
15770e9298 Update dependency @types/node to v18.19.9 (#10358)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-23 15:42:57 +00:00
github-actions[bot]
06797ec0ab @archisman-panigrahi has signed the CLA in laurent22/joplin#10355 2024-04-22 21:55:42 +00:00
Henry Heino
b69bf84ab6 Desktop: Fixes #10345: Linux: Allow passing --enable-wayland-ime flag to fix input method issues on startup (#10349) 2024-04-22 18:28:26 +01:00
Abdelrrahman Elhaddad
7ec02fc8d8 Desktop: Added search list for configuration font input fields (#10248) 2024-04-20 14:23:07 +01:00
Dmitriy Q
dd28c9f4d7 Update russian translation (#10325) 2024-04-20 14:07:30 +01:00
Henry Heino
7fe98e9dc9 Desktop: Resolves #10332: Ubuntu 24.04: Work around unprivileged user namespace restrictions by adding the --no-sandbox flag to the launcher (#10338) 2024-04-20 13:52:26 +01:00
Akira Taguchi
6358c39810 Doc: Update debugging.md (#10329) 2024-04-20 13:51:22 +01:00
wljince007
294cc4a440 Desktop: Fixes #5626: When web clipper clipping code blocks, keep code in multiline and delete code number lines (#10126)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2024-04-20 13:48:44 +01:00
Joplin Bot
ae1347bb7c Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-04-20 12:19:56 +00:00
Laurent Cozic
b6d659baf2 iOS 13.0.1 2024-04-20 11:38:52 +01:00
Laurent Cozic
5756e160da lock file 2024-04-20 11:37:59 +01:00
Laurent Cozic
113c046de6 Doc: Update prereleases.md 2024-04-20 11:32:29 +01:00
github-actions[bot]
1ef4e574b7 @akirataguchi115 has signed the CLA in laurent22/joplin#10329 2024-04-18 18:18:30 +00:00
Joplin Bot
eccc74cf72 Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-04-18 18:17:34 +00:00
pedr
a4137a83d8 Desktop, Mobile: Display a message when Joplin Cloud user don't have access to email to note feature (#10322) 2024-04-18 14:29:49 +01:00
github-actions[bot]
ed9b4fb831 @LightTreasure has signed the CLA in laurent22/joplin#10324 2024-04-18 03:56:56 +00:00
CoolCu
1d31f63947 Doc: remove repetitive words (#10317)
Signed-off-by: CoolCu <coolcui@qq.com>
2024-04-17 11:55:33 +01:00
chaNcharge
74cda4e2ab Desktop: Resolves #10315: Do not trim markdown upon saving in rich text (#10321) 2024-04-17 10:19:25 +01:00
Joplin Bot
39db5cd061 Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-04-16 12:20:42 +00:00
github-actions[bot]
11d9e0a72f @CoolCu has signed the CLA in laurent22/joplin#10317 2024-04-16 08:11:16 +00:00
Laurent Cozic
7683284352 Android 3.0.2 2024-04-15 19:17:10 +01:00
181 changed files with 4069 additions and 1452 deletions

View File

@@ -167,6 +167,7 @@ packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
packages/app-desktop/gui/ConfigScreen/FontSearch.js
packages/app-desktop/gui/ConfigScreen/Sidebar.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
@@ -395,10 +396,26 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js
packages/app-desktop/gui/SearchBar/SearchBar.js
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
packages/app-desktop/gui/ShareNoteDialog.js
packages/app-desktop/gui/Sidebar/FolderAndTagList.js
packages/app-desktop/gui/Sidebar/Sidebar.js
packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
packages/app-desktop/gui/Sidebar/commands/index.js
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
packages/app-desktop/gui/Sidebar/styles/index.js
packages/app-desktop/gui/Sidebar/types.js
packages/app-desktop/gui/StatusScreen/StatusScreen.js
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
packages/app-desktop/gui/SyncWizard/Dialog.js
@@ -413,6 +430,7 @@ packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/dialogs.js
packages/app-desktop/gui/hooks/useEffectDebugger.js
packages/app-desktop/gui/hooks/useElementHeight.js
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
packages/app-desktop/gui/hooks/usePrevious.js
packages/app-desktop/gui/hooks/usePropsDebugger.js
@@ -433,6 +451,8 @@ packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/sidebar.spec.js
packages/app-desktop/integration-tests/simpleBackup.spec.js
packages/app-desktop/integration-tests/util/activateMainMenuItem.js
packages/app-desktop/integration-tests/util/createStartupArgs.js
@@ -488,6 +508,7 @@ packages/app-mobile/components/ActionButton.js
packages/app-mobile/components/BackButtonDialogBox.js
packages/app-mobile/components/CameraView.js
packages/app-mobile/components/CustomButton.js
packages/app-mobile/components/DismissibleDialog.js
packages/app-mobile/components/Dropdown.test.js
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/ExtendedWebView.js
@@ -584,6 +605,7 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js
packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.js
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.js
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js
@@ -665,6 +687,7 @@ packages/app-mobile/utils/fs-driver/tarExtract.test.js
packages/app-mobile/utils/fs-driver/tarExtract.js
packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
@@ -776,6 +799,7 @@ packages/lib/ObjectUtils.js
packages/lib/PoorManIntervals.js
packages/lib/RotatingLogs.test.js
packages/lib/RotatingLogs.js
packages/lib/SyncTargetFilesystem.js
packages/lib/SyncTargetJoplinCloud.js
packages/lib/SyncTargetJoplinServer.js
packages/lib/SyncTargetNone.js
@@ -794,6 +818,7 @@ packages/lib/commands/index.js
packages/lib/commands/openMasterPasswordDialog.js
packages/lib/commands/synchronize.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/config/config-shared.js
packages/lib/components/shared/config/plugins/types.js
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
@@ -815,6 +840,7 @@ packages/lib/errorUtils.js
packages/lib/errors.js
packages/lib/eventManager.js
packages/lib/file-api-driver-joplinServer.js
packages/lib/file-api-driver-local.js
packages/lib/file-api-driver-memory.js
packages/lib/file-api-driver.test.js
packages/lib/file-api.test.js
@@ -1040,9 +1066,12 @@ packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
packages/lib/services/plugins/reducer.js
packages/lib/services/plugins/utils/createViewHandle.js
packages/lib/services/plugins/utils/executeSandboxCall.js
packages/lib/services/plugins/utils/getPluginIssueReportUrl.test.js
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/isCompatible/getDefaultPlatforms.js
packages/lib/services/plugins/utils/isCompatible/index.test.js
packages/lib/services/plugins/utils/isCompatible/index.js
packages/lib/services/plugins/utils/isCompatible/minVersionForPlatform.js

View File

@@ -70,6 +70,10 @@ module.exports = {
'no-var': ['error'],
'no-new-func': ['error'],
'import/prefer-default-export': ['error'],
'prefer-promise-reject-errors': ['error', {
allowEmptyReject: true,
}],
'no-throw-literal': ['error'],
// This rule should not be enabled since it matters in what order
// imports are done, in particular in relation to the shim.setReact

29
.gitignore vendored
View File

@@ -147,6 +147,7 @@ packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
packages/app-desktop/gui/ConfigScreen/FontSearch.js
packages/app-desktop/gui/ConfigScreen/Sidebar.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
@@ -375,10 +376,26 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js
packages/app-desktop/gui/SearchBar/SearchBar.js
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
packages/app-desktop/gui/ShareNoteDialog.js
packages/app-desktop/gui/Sidebar/FolderAndTagList.js
packages/app-desktop/gui/Sidebar/Sidebar.js
packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
packages/app-desktop/gui/Sidebar/commands/index.js
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
packages/app-desktop/gui/Sidebar/styles/index.js
packages/app-desktop/gui/Sidebar/types.js
packages/app-desktop/gui/StatusScreen/StatusScreen.js
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
packages/app-desktop/gui/SyncWizard/Dialog.js
@@ -393,6 +410,7 @@ packages/app-desktop/gui/ToolbarSpace.js
packages/app-desktop/gui/TrashNotification/TrashNotification.js
packages/app-desktop/gui/dialogs.js
packages/app-desktop/gui/hooks/useEffectDebugger.js
packages/app-desktop/gui/hooks/useElementHeight.js
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
packages/app-desktop/gui/hooks/usePrevious.js
packages/app-desktop/gui/hooks/usePropsDebugger.js
@@ -413,6 +431,8 @@ packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/sidebar.spec.js
packages/app-desktop/integration-tests/simpleBackup.spec.js
packages/app-desktop/integration-tests/util/activateMainMenuItem.js
packages/app-desktop/integration-tests/util/createStartupArgs.js
@@ -468,6 +488,7 @@ packages/app-mobile/components/ActionButton.js
packages/app-mobile/components/BackButtonDialogBox.js
packages/app-mobile/components/CameraView.js
packages/app-mobile/components/CustomButton.js
packages/app-mobile/components/DismissibleDialog.js
packages/app-mobile/components/Dropdown.test.js
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/ExtendedWebView.js
@@ -564,6 +585,7 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js
packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.js
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.js
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js
@@ -645,6 +667,7 @@ packages/app-mobile/utils/fs-driver/tarExtract.test.js
packages/app-mobile/utils/fs-driver/tarExtract.js
packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
@@ -756,6 +779,7 @@ packages/lib/ObjectUtils.js
packages/lib/PoorManIntervals.js
packages/lib/RotatingLogs.test.js
packages/lib/RotatingLogs.js
packages/lib/SyncTargetFilesystem.js
packages/lib/SyncTargetJoplinCloud.js
packages/lib/SyncTargetJoplinServer.js
packages/lib/SyncTargetNone.js
@@ -774,6 +798,7 @@ packages/lib/commands/index.js
packages/lib/commands/openMasterPasswordDialog.js
packages/lib/commands/synchronize.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/config/config-shared.js
packages/lib/components/shared/config/plugins/types.js
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
@@ -795,6 +820,7 @@ packages/lib/errorUtils.js
packages/lib/errors.js
packages/lib/eventManager.js
packages/lib/file-api-driver-joplinServer.js
packages/lib/file-api-driver-local.js
packages/lib/file-api-driver-memory.js
packages/lib/file-api-driver.test.js
packages/lib/file-api.test.js
@@ -1020,9 +1046,12 @@ packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
packages/lib/services/plugins/reducer.js
packages/lib/services/plugins/utils/createViewHandle.js
packages/lib/services/plugins/utils/executeSandboxCall.js
packages/lib/services/plugins/utils/getPluginIssueReportUrl.test.js
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/isCompatible/getDefaultPlatforms.js
packages/lib/services/plugins/utils/isCompatible/index.test.js
packages/lib/services/plugins/utils/isCompatible/index.js
packages/lib/services/plugins/utils/isCompatible/minVersionForPlatform.js

3
CONTRIBUTING Normal file
View File

@@ -0,0 +1,3 @@
# Contributing to Joplin
See the guide at https://github.com/laurent22/joplin/blob/dev/readme/dev/index.md

View File

@@ -202,16 +202,28 @@ if command -v lsb_release &> /dev/null; then
DISTVER=$(lsb_release -is) && DISTVER=$DISTVER$(lsb_release -rs)
DISTCODENAME=$(lsb_release -cs)
DISTMAJOR=$(lsb_release -rs|cut -d. -f1)
#-----------------------------------------------------
# Check for "The SUID sandbox helper binary was found, but is not configured correctly" problem.
# It is present in Debian 1X. A (temporary) patch will be applied at .desktop file
# Linux Mint 4 Debbie is based on Debian 10 and requires the same param handling.
#
# This also works around Ubuntu 23.10+'s restrictions on unprivileged user namespaces. Electron
# TODO: Remove: This is likely no longer an issue. See https://issues.chromium.org/issues/40462640.
BAD_HELPER_BINARY=false
if [[ $DISTVER =~ Debian1. || ( "$DISTVER" = "Linuxmint4" && "$DISTCODENAME" = "debbie" ) || ( "$DISTVER" = "CentOS" && "$DISTMAJOR" =~ 6|7 ) ]]; then
BAD_HELPER_BINARY=true
fi
# Work around Ubuntu 23.10+'s restrictions on unprivileged user namespaces. Electron
# uses these to sandbox processes. Unfortunately, it doesn't look like we can get around this
# without writing the AppImage to a non-user-writable location (without invalidating other security
# controls). See https://discourse.joplinapp.org/t/possible-future-requirement-for-no-sandbox-flag-for-ubuntu-23-10/.
if [[ $DISTVER = "Ubuntu23.10" || $DISTVER =~ Debian1. || ( "$DISTVER" = "Linuxmint4" && "$DISTCODENAME" = "debbie" ) || ( "$DISTVER" = "CentOS" && "$DISTMAJOR" =~ 6|7 ) ]]; then
HAS_USERNS_RESTRICTIONS=false
if [[ "$DISTVER" =~ ^Ubuntu && $DISTMAJOR -ge 23 ]]; then
HAS_USERNS_RESTRICTIONS=true
fi
if [[ $HAS_USERNS_RESTRICTIONS = true || $BAD_HELPER_BINARY = true ]]; then
SANDBOXPARAM="--no-sandbox"
print "${COLOR_YELLOW}WARNING${COLOR_RESET} Electron sandboxing disabled."
print " See https://discourse.joplinapp.org/t/32160/5 for details."
@@ -241,7 +253,7 @@ if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cin
Encoding=UTF-8
Name=Joplin
Comment=Joplin for Desktop
Exec=${HOME}/.joplin/Joplin.AppImage ${SANDBOXPARAM} %u
Exec=env APPIMAGELAUNCHER_DISABLE=TRUE ${HOME}/.joplin/Joplin.AppImage ${SANDBOXPARAM} %u
Icon=joplin
StartupWMClass=Joplin
Type=Application

View File

@@ -9,7 +9,7 @@
#
# APP_BASE_URL: This is the base public URL where the service will be running.
# - If Joplin Server needs to be accessible over the internet, configure APP_BASE_URL as follows: https://example.com/joplin.
# - If Joplin Server does not need to be accessible over the internet, set the the APP_BASE_URL to your server's hostname.
# - If Joplin Server does not need to be accessible over the internet, set the APP_BASE_URL to your server's hostname.
# For Example: http://[hostname]:22300. The base URL can include the port.
# APP_PORT: The local port on which the Docker container will listen.
# - This would typically be mapped to port to 443 (TLS) with a reverse proxy.

View File

@@ -9,7 +9,7 @@
"url": "git+https://github.com/laurent22/joplin.git"
},
"engines": {
"node": ">=16"
"node": ">=18"
},
"scripts": {
"buildApiDoc": "yarn workspace joplin start apidoc ../../readme/api/references/rest_api.md",

View File

@@ -452,7 +452,7 @@ class Application extends BaseApplication {
Setting.dispatchUpdateAll();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
await refreshFolders((action: any) => this.store().dispatch(action));
await refreshFolders((action: any) => this.store().dispatch(action), '');
const tags = await Tag.allWithNotes();

View File

@@ -26,7 +26,7 @@ const sharp = require('sharp');
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const shim = require('@joplin/lib/shim').default;
const { _ } = require('@joplin/lib/locale');
const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local');
const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default;
const EncryptionService = require('@joplin/lib/services/e2ee/EncryptionService').default;
const envFromArgs = require('@joplin/lib/envFromArgs');
const nodeSqlite = require('sqlite3');

View File

@@ -5,6 +5,8 @@ import executeSandboxCall from '@joplin/lib/services/plugins/utils/executeSandbo
import Global from '@joplin/lib/services/plugins/api/Global';
import mapEventHandlersToIds, { EventHandlers } from '@joplin/lib/services/plugins/utils/mapEventHandlersToIds';
import uuid from '@joplin/lib/uuid';
import Joplin from '@joplin/lib/services/plugins/api/Joplin';
import { Console } from 'console';
const sandboxProxy = require('@joplin/lib/services/plugins/sandboxProxy');
function createConsoleWrapper(pluginId: string) {
@@ -33,11 +35,18 @@ function createConsoleWrapper(pluginId: string) {
// For example, all plugin calls go through a proxy, however they could made directly since
// the plugin script is running within the same process as the main app.
interface SandboxProxy {
joplin: Joplin;
console: typeof Console;
stop: ()=> void;
}
export default class PluginRunner extends BasePluginRunner {
private eventHandlers_: EventHandlers = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private activeSandboxCalls_: any = {};
private sandboxProxies: Map<string, SandboxProxy> = new Map();
public constructor() {
super();
@@ -52,8 +61,14 @@ export default class PluginRunner extends BasePluginRunner {
}
private newSandboxProxy(pluginId: string, sandbox: Global) {
let stopped = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const target = async (path: string, args: any[]) => {
if (stopped) {
throw new Error(`Plugin with ID ${pluginId} has been stopped. Cannot execute sandbox call.`);
}
const callId = `${pluginId}::${path}::${uuid.createNano()}`;
this.activeSandboxCalls_[callId] = true;
const promise = executeSandboxCall(pluginId, sandbox, `joplin.${path}`, mapEventHandlersToIds(args, this.eventHandlers_), this.eventHandler);
@@ -64,10 +79,15 @@ export default class PluginRunner extends BasePluginRunner {
return promise;
};
return {
const proxy = {
joplin: sandboxProxy(target),
console: createConsoleWrapper(pluginId),
stop: () => {
stopped = true;
},
};
this.sandboxProxies.set(pluginId, proxy);
return proxy;
}
public async run(plugin: Plugin, sandbox: Global): Promise<void> {
@@ -90,6 +110,13 @@ export default class PluginRunner extends BasePluginRunner {
});
}
public async stop(plugin: Plugin): Promise<void> {
// TODO: Node VM doesn't support stopping plugins without running them in a child process.
// For now, we stop the plugin by making interactions with the Joplin API throw Errors.
const proxy = this.sandboxProxies.get(plugin.id);
proxy?.stop();
}
public async waitForSandboxCalls(): Promise<void> {
const startTime = Date.now();
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied

View File

@@ -73,7 +73,7 @@
"@joplin/tools": "~3.0",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.8",
"@types/node": "18.19.8",
"@types/node": "18.19.10",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",

View File

@@ -0,0 +1,24 @@
<!-- this is from: https://flaviocopes.com/bubble-sort-javascript/ -->
<div><pre class="astro-code github-dark" style="background-color: rgb(36, 41, 46); color: rgb(225, 228, 232); overflow-x: auto; --darkreader-inline-bgcolor: #1d2125; --darkreader-inline-color: #d7d4cf; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, &quot;Courier New&quot;, monospace;" tabindex="0" data-darkreader-inline-bgcolor="" data-darkreader-inline-color=""><code><span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">const</span><span style="color: rgb(179, 146, 240); --darkreader-inline-color: #ab86ee;" data-darkreader-inline-color=""> bubbleSort</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> =</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> (</span><span style="color: rgb(255, 171, 112); --darkreader-inline-color: #ffa668;" data-darkreader-inline-color="">originalArray</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">) </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=&gt;</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> {</span></span>
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> let</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> swapped </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> false</span></span>
<span class="line"></span>
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> const</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> a</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> =</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> [</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">...</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">originalArray]</span></span>
<span class="line"></span>
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> for</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> (</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">let</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> i </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 1</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">; i </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">&lt;</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> a.</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color="">length</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> -</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 1</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">; i</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">++</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">) {</span></span>
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> swapped </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> false</span></span>
<span class="line"></span>
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> for</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> (</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">let</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> j </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 0</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">; j </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">&lt;</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> a.</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color="">length</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> -</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> i; j</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">++</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">) {</span></span>
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> if</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> (a[j </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">+</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 1</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">] </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">&lt;</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> a[j]) {</span></span>
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> ;[a[j], a[j </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">+</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 1</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">]] </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> [a[j </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">+</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 1</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">], a[j]]</span></span>
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> swapped </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> true</span></span>
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> }</span></span>
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> }</span></span>
<span class="line"></span>
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> if</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> (</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">!</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">swapped) {</span></span>
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> return</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> a</span></span>
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> }</span></span>
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> }</span></span>
<span class="line"></span>
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> return</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> a</span></span>
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">}</span></span></code></pre>
<p></p></div>

View File

@@ -0,0 +1,24 @@
```
const bubbleSort = (originalArray) => {
let swapped = false
const a = [...originalArray]
for (let i = 1; i < a.length - 1; i++) {
swapped = false
for (let j = 0; j < a.length - i; j++) {
if (a[j + 1] < a[j]) {
;[a[j], a[j + 1]] = [a[j + 1], a[j]]
swapped = true
}
}
if (!swapped) {
return a
}
}
return a
}
```

View File

@@ -0,0 +1,21 @@
<!-- this is from: https://blog.csdn.net/qq_42025798/article/details/121727781 -->
<div><pre data-index="6" class="prettyprint set-code-show" style="font-family: &quot;Source Code Pro&quot;, &quot;DejaVu Sans Mono&quot;, &quot;Ubuntu Mono&quot;, &quot;Anonymous Pro&quot;, &quot;Droid Sans Mono&quot;, Menlo, Monaco, Consolas, Inconsolata, Courier, monospace, &quot;PingFang SC&quot;, &quot;Microsoft YaHei&quot;, sans-serif;"><code class="prism language-java has-numbering" onclick="mdcp.copyCode(event)" style="position: unset;"><span class="token keyword">public</span> <span class="token keyword">class</span> <span class="token class-name">User</span> <span class="token punctuation">{</span>
<span class="token keyword">private</span> <span class="token class-name">String</span> name<span class="token punctuation">;</span>
<span class="token keyword">public</span> <span class="token class-name">String</span> <span class="token function">getName</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
<span class="token keyword">return</span> name<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token keyword">public</span> <span class="token keyword">void</span> <span class="token function">setName</span><span class="token punctuation">(</span><span class="token class-name">String</span> name<span class="token punctuation">)</span> <span class="token punctuation">{</span>
<span class="token keyword">this</span><span class="token punctuation">.</span>name <span class="token operator">=</span> name<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token annotation punctuation">@Override</span>
<span class="token keyword">public</span> <span class="token class-name">String</span> <span class="token function">toString</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
<span class="token keyword">return</span> <span class="token string">"User{"</span> <span class="token operator">+</span>
<span class="token string">"name='"</span> <span class="token operator">+</span> name <span class="token operator">+</span> <span class="token string">'\''</span> <span class="token operator">+</span>
<span class="token string">'}'</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code><ul class="pre-numbering" style=""><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">1</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">2</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">3</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">4</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">5</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">6</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">7</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">8</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">9</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">10</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">11</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">12</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">13</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">14</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">15</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">16</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">17</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">18</li></ul></pre>
<h4></h4></div>

View File

@@ -0,0 +1,20 @@
```
public class User {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
'}';
}
}
```

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,16 @@
```typescript
// Import the Joplin API
import joplin from 'api';
// Register the plugin
joplin.plugins.register({
// Run initialisation code in the onStart event handler
// Note that due to the plugin multi-process architecture, you should
// always assume that all function calls and event handlers are async.
onStart: async function() {
console.info('TOC plugin started!');
},
});
```

View File

@@ -0,0 +1,2 @@
<!-- this is from: https://blog.csdn.net/ch5256865/article/details/52957360 -->
<pre data-index="0" class="set-code-show" name="code" style="font-family: Consolas, Inconsolata, Courier, monospace;"><code class="hljs language-javascript"><ol class="hljs-ln" style="width:100%"><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="1"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"><span class="hljs-keyword">let</span> str = <span class="hljs-string">` hello world ! `</span>;</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="2"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"><span class="hljs-keyword">let</span> _trim = <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) {</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="3"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> <span class="hljs-variable language_">this</span> == <span class="hljs-string">"number"</span>) <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"Invalid or unexpected token"</span>);</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="4"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> <span class="hljs-variable language_">this</span> !== <span class="hljs-string">"string"</span>)</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="5"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"Cannot read property 'trim' of"</span> + <span class="hljs-variable language_">this</span>);</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="6"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> <span class="hljs-keyword">let</span> reg = <span class="hljs-regexp">/^\s*|\s*$/g</span>;</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="7"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> <span class="hljs-keyword">return</span> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">replace</span>(reg, <span class="hljs-string">""</span>);</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="8"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line">};</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="9"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> </div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="10"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"><span class="hljs-title class_">String</span>.<span class="hljs-property"><span class="hljs-keyword">prototype</span></span>.<span class="hljs-property">_trim</span> = _trim;</div></div></li></ol></code></pre>

View File

@@ -0,0 +1,12 @@
```
let str = ` hello world ! `;
let _trim = function () {
if (typeof this == "number") new Error("Invalid or unexpected token");
if (typeof this !== "string")
new Error("Cannot read property 'trim' of" + this);
let reg = /^\s*|\s*$/g;
return this.replace(reg, "");
};
String.prototype._trim = _trim;
```

View File

@@ -0,0 +1,42 @@
<!-- this is from: https://www.slingacademy.com/article/python-aiohttp-how-to-download-files-using-streams/ -->
<div><pre class="wp-block-code prettyprinted" style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;liberation mono&quot;, &quot;courier new&quot;, monospace;"><code class=" prettyprinted" style=""><span class="com"><span class="com"># SlingAcademy.com</span></span><span class="pln"><span class="pln">
</span></span><span class="com"><span class="com"># This code uses Python 3.11.4</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">import</span></span><span class="pln"><span class="pln"> asyncio
</span></span><span class="kwd"><span class="kwd">import</span></span><span class="pln"><span class="pln"> aiohttp
</span></span><span class="com"><span class="com"># This function downloads a file from a URL and saves it to a local file</span></span><span class="pln"><span class="pln">
</span></span><span class="com"><span class="com"># The function is asynchronous and can handle large files because it uses aiohttp streams</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">async</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">def</span></span><span class="pln"><span class="pln"> download_file</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">url</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln"> filename</span></span><span class="pun"><span class="pun">):</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">async</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">with</span></span><span class="pln"><span class="pln"> aiohttp</span></span><span class="pun"><span class="pun">.</span></span><span class="typ"><span class="typ">ClientSession</span></span><span class="pun"><span class="pun">()</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">as</span></span><span class="pln"><span class="pln"> session</span></span><span class="pun"><span class="pun">:</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">print</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">f</span></span><span class="str"><span class="str">"Starting download file from {url}"</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">async</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">with</span></span><span class="pln"><span class="pln"> session</span></span><span class="pun"><span class="pun">.</span></span><span class="kwd"><span class="kwd">get</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">url</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">as</span></span><span class="pln"><span class="pln"> response</span></span><span class="pun"><span class="pun">:</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">assert</span></span><span class="pln"><span class="pln"> response</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">status </span></span><span class="pun"><span class="pun">==</span></span><span class="pln"><span class="pln"> </span></span><span class="lit"><span class="lit">200</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">with</span></span><span class="pln"><span class="pln"> open</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">filename</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln"> </span></span><span class="str"><span class="str">"wb"</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">as</span></span><span class="pln"><span class="pln"> f</span></span><span class="pun"><span class="pun">:</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">while</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">True</span></span><span class="pun"><span class="pun">:</span></span><span class="pln"><span class="pln">
chunk </span></span><span class="pun"><span class="pun">=</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">await</span></span><span class="pln"><span class="pln"> response</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">content</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">readany</span></span><span class="pun"><span class="pun">()</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">if</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">not</span></span><span class="pln"><span class="pln"> chunk</span></span><span class="pun"><span class="pun">:</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">break</span></span><span class="pln"><span class="pln">
f</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">write</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">chunk</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">print</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">f</span></span><span class="str"><span class="str">"Downloaded {filename} from {url}"</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln">
</span></span><span class="com"><span class="com"># This function downloads two files at the same time</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">async</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">def</span></span><span class="pln"><span class="pln"> main</span></span><span class="pun"><span class="pun">():</span></span><span class="pln"><span class="pln">
</span></span><span class="kwd"><span class="kwd">await</span></span><span class="pln"><span class="pln"> asyncio</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">gather</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">
</span></span><span class="com"><span class="com"># download a CSV file</span></span><span class="pln"><span class="pln">
download_file</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">
</span></span><span class="str"><span class="str">"https://api.slingacademy.com/v1/sample-data/files/student-scores.csv"</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln">
</span></span><span class="str"><span class="str">"test.csv"</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln">
</span></span><span class="pun"><span class="pun">),</span></span><span class="pln"><span class="pln">
</span></span><span class="com"><span class="com"># download a PDF file</span></span><span class="pln"><span class="pln">
download_file</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">
</span></span><span class="str"><span class="str">"https://api.slingacademy.com/v1/sample-data/files/text-and-table.pdf"</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln">
</span></span><span class="str"><span class="str">"test.pdf"</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln">
</span></span><span class="pun"><span class="pun">),</span></span><span class="pln"><span class="pln">
</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln">
</span></span><span class="com"><span class="com"># Run the main function</span></span><span class="pln"><span class="pln">
asyncio</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">run</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">main</span></span><span class="pun"><span class="pun">())</span></span></code></pre><span class="ezoic-autoinsert-video ezoic-mid_content"></span>
<p></p></div>

View File

@@ -0,0 +1,42 @@
```
# SlingAcademy.com
# This code uses Python 3.11.4
import asyncio
import aiohttp
# This function downloads a file from a URL and saves it to a local file
# The function is asynchronous and can handle large files because it uses aiohttp streams
async def download_file(url, filename):
async with aiohttp.ClientSession() as session:
print(f"Starting download file from {url}")
async with session.get(url) as response:
assert response.status == 200
with open(filename, "wb") as f:
while True:
chunk = await response.content.readany()
if not chunk:
break
f.write(chunk)
print(f"Downloaded {filename} from {url}")
# This function downloads two files at the same time
async def main():
await asyncio.gather(
# download a CSV file
download_file(
"https://api.slingacademy.com/v1/sample-data/files/student-scores.csv",
"test.csv",
),
# download a PDF file
download_file(
"https://api.slingacademy.com/v1/sample-data/files/text-and-table.pdf",
"test.pdf",
),
)
# Run the main function
asyncio.run(main())
```

View File

@@ -0,0 +1 @@
<div><span class="jop-noMdConv">This is a comment we would like to keep</div></form>

View File

@@ -0,0 +1 @@
<form><span>This is a comment we would like to keep</span></form>

View File

@@ -1,5 +1,5 @@
import PluginRunner from '../../../app/services/plugins/PluginRunner';
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
import MdToHtml from '@joplin/renderer/MdToHtml';
import shim from '@joplin/lib/shim';
@@ -9,6 +9,7 @@ import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import { expectNotThrow, setupDatabaseAndSynchronizer, switchClient, expectThrow, createTempDir, supportDir, mockMobilePlatform } from '@joplin/lib/testing/test-utils';
import { newPluginScript } from '../../testUtils';
import { join } from 'path';
const testPluginDir = `${supportDir}/plugins`;
@@ -283,10 +284,11 @@ describe('services_PluginService', () => {
shouldRun: true,
},
{
// Should default to desktop-only
manifestPlatforms: [],
isDesktop: false,
appVersion: '3.0.8',
shouldRun: true,
shouldRun: false,
},
])('should enable and disable plugins depending on what platform(s) they support (case %#: %j)', async ({ manifestPlatforms, isDesktop, appVersion, shouldRun }) => {
const pluginScript = `
@@ -396,4 +398,78 @@ describe('services_PluginService', () => {
expect(newPluginSettings[pluginId1]).toBe(undefined);
expect(newPluginSettings[pluginId2]).toBe(undefined);
});
it('re-running loadAndRunPlugins should reload plugins that have changed but keep unchanged plugins running', async () => {
const testDir = await createTempDir();
try {
const loadCounterNote = await Note.save({ title: 'Log of plugin loads' });
const readLoadCounterNote = async () => {
return (await Note.load(loadCounterNote.id)).body;
};
expect(await readLoadCounterNote()).toBe('');
const writePluginScript = async (version: string, id: string) => {
const script = `
/* joplin-manifest:
{
"id": ${JSON.stringify(id)},
"manifest_version": 1,
"app_min_version": "1.0.0",
"name": "JS Bundle test",
"version": ${JSON.stringify(version)}
}
*/
joplin.plugins.register({
onStart: async function() {
const noteId = ${JSON.stringify(loadCounterNote.id)};
const pluginId = ${JSON.stringify(id)};
const note = await joplin.data.get(['notes', noteId], { fields: ['body'] });
const newBody = note.body + '\\n' + pluginId;
await joplin.data.put(['notes', noteId], null, { body: newBody.trim() });
},
});
`;
await fs.writeFile(join(testDir, `${id}.bundle.js`), script);
};
const service = newPluginService();
const pluginId1 = 'org.joplinapp.testPlugin1';
await writePluginScript('0.0.1', pluginId1);
const pluginId2 = 'org.joplinapp.testPlugin2';
await writePluginScript('0.0.1', pluginId2);
let pluginSettings: PluginSettings = {
[pluginId1]: defaultPluginSetting(),
[pluginId2]: defaultPluginSetting(),
};
await service.loadAndRunPlugins(testDir, pluginSettings);
// Plugins should initially load once
expect(service.pluginIds).toHaveLength(2);
expect(service.pluginById(pluginId1).running).toBe(true);
expect(service.pluginById(pluginId2).running).toBe(true);
expect(await readLoadCounterNote()).toBe(`${pluginId1}\n${pluginId2}`);
// Updating just plugin 1 reload just plugin 1.
await writePluginScript('0.0.2', pluginId1);
await service.loadAndRunPlugins(testDir, pluginSettings);
expect(service.pluginById(pluginId1).running).toBe(true);
expect(service.pluginById(pluginId2).running).toBe(true);
expect(await readLoadCounterNote()).toBe(`${pluginId1}\n${pluginId2}\n${pluginId1}`);
// Disabling plugin 1 should not reload plugin 2
pluginSettings = { ...pluginSettings, [pluginId1]: { ...defaultPluginSetting(), enabled: false } };
await service.loadAndRunPlugins(testDir, pluginSettings);
expect(service.pluginById(pluginId1).running).toBe(false);
expect(service.pluginById(pluginId2).running).toBe(true);
expect(await readLoadCounterNote()).toBe(`${pluginId1}\n${pluginId2}\n${pluginId1}`);
await service.destroy();
} finally {
await fs.remove(testDir);
}
});
});

View File

@@ -47,6 +47,42 @@ const registerMakeThumbnailCommand = async () => {
await joplin.views.toolbarButtons.create('makeThumbnailButton', 'makeThumbnail', ToolbarButtonLocation.EditorToolbar);
};
const registerMakeThumbnailFromUrlCommand = async () => {
await joplin.commands.register({
name: 'makeThumbnailFromUrl',
execute: async () => {
const urls = [
'https://github.com/laurent22/joplin/blob/dev/Assets/ImageSources/RoundedCornersMac_1024x1024.png?raw=true',
'https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/ocr_samples/multi_page__embedded_text.pdf?raw=true',
]
for (const url of urls) {
// ---------------------------------------------------------------
// Create an image from URLs
// ---------------------------------------------------------------
const imageHandle = await joplin.imaging.createFromPath(url);
const resizedImageHandle = await joplin.imaging.resize(imageHandle, { width: 100 });
// ---------------------------------------------------------------
// Convert the image to a resource and add it to the note
// ---------------------------------------------------------------
const newResource = await joplin.imaging.toJpgResource(resizedImageHandle, { title: "Thumbnail" });
await joplin.commands.execute('insertText', '\n![](:/' + newResource.id + ')');
// ---------------------------------------------------------------
// Free up the image objects at the end
// ---------------------------------------------------------------
await joplin.imaging.free(imageHandle);
await joplin.imaging.free(resizedImageHandle);
}
},
});
await joplin.views.toolbarButtons.create('makeThumbnailFromUrlButton', 'makeThumbnailFromUrl', ToolbarButtonLocation.EditorToolbar);
};
const registerInlinePdfCommand = async () => {
await joplin.commands.register({
@@ -106,5 +142,6 @@ joplin.plugins.register({
onStart: async function() {
await registerMakeThumbnailCommand();
await registerInlinePdfCommand();
await registerMakeThumbnailFromUrlCommand();
},
});

View File

@@ -10,5 +10,5 @@ document.addEventListener('click', async (event) => {
})
console.info('webview.js: registering message listener');
webviewApi.onMessage((message) => console.info('webview.js: got message:', message));
webviewApi.onMessage((event) => console.info('webview.js: got message:', event.message));

View File

@@ -390,6 +390,8 @@ class Application extends BaseApplication {
argv = await super.start(argv, startOptions);
bridge().setLogFilePath(Logger.globalLogger.logFilePath());
await this.applySettingsSideEffects();
if (Setting.value('sync.upgradeState') === Setting.SYNC_UPGRADE_STATE_MUST_DO) {
@@ -465,7 +467,7 @@ class Application extends BaseApplication {
Setting.dispatchUpdateAll();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
await refreshFolders((action: any) => this.dispatch(action));
await refreshFolders((action: any) => this.dispatch(action), '');
const tags = await Tag.allWithNotes();

View File

@@ -6,11 +6,14 @@ import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
import { fileUriToPath } from '@joplin/utils/url';
import { urlDecode } from '@joplin/lib/string-utils';
import * as Sentry from '@sentry/electron/main';
import { ErrorEvent } from '@sentry/types/types';
import { homedir } from 'os';
import { msleep } from '@joplin/utils/time';
import { pathExists, writeFileSync } from 'fs-extra';
import { pathExists, pathExistsSync, writeFileSync } from 'fs-extra';
import { extname, normalize } from 'path';
import isSafeToOpen from './utils/isSafeToOpen';
import { closeSync, openSync, readSync, statSync } from 'fs';
import { KB } from '@joplin/utils/bytes';
interface LastSelectedPath {
file: string;
@@ -38,6 +41,7 @@ export class Bridge {
private rootProfileDir_: string;
private appName_: string;
private appId_: string;
private logFilePath_ = '';
private extraAllowedExtensions_: string[] = [];
private onAllowedExtensionsChangeListener_: OnAllowedExtensionsChange = ()=>{};
@@ -56,12 +60,53 @@ export class Bridge {
this.sentryInit();
}
public setLogFilePath(v: string) {
this.logFilePath_ = v;
}
private sentryInit() {
const getLogLines = () => {
try {
if (!this.logFilePath_ || !pathExistsSync(this.logFilePath_)) return '';
const { size } = statSync(this.logFilePath_);
if (!size) return '';
const bytesToRead = Math.min(size, 100 * KB);
const handle = openSync(this.logFilePath_, 'r');
const position = size - bytesToRead;
const buffer = Buffer.alloc(bytesToRead);
readSync(handle, buffer, 0, bytesToRead, position);
closeSync(handle);
return buffer.toString('utf-8');
} catch (error) {
// Can't do anything in this context
return '';
}
};
const getLogAttachment = () => {
const lines = getLogLines();
if (!lines) return null;
return { filename: 'joplin-log.txt', data: lines };
};
const options: Sentry.ElectronMainOptions = {
beforeSend: event => {
beforeSend: (event, hint) => {
try {
const logAttachment = getLogAttachment();
if (logAttachment) hint.attachments = [logAttachment];
const date = (new Date()).toISOString().replace(/[:-]/g, '').split('.')[0];
writeFileSync(`${homedir()}/joplin_crash_dump_${date}.json`, JSON.stringify(event, null, '\t'), 'utf-8');
interface ErrorEventWithLog extends ErrorEvent {
log: string[];
}
const errorEventWithLog: ErrorEventWithLog = {
...event,
log: logAttachment ? logAttachment.data.trim().split('\n') : [],
};
writeFileSync(`${homedir()}/joplin_crash_dump_${date}.json`, JSON.stringify(errorEventWithLog, null, '\t'), 'utf-8');
} catch (error) {
// Ignore the error since we can't handle it here
}

View File

@@ -19,7 +19,7 @@ const generateChecksumFile = () => {
}
}
if (appImageName === '') {
throw 'AppImage not found!';
throw new Error('AppImage not found!');
}
const appImagePath = path.join(distPath, appImageName);
const appImageContent = fs.readFileSync(appImagePath);

View File

@@ -22,6 +22,7 @@ interface Props {
title?: string;
iconName?: string;
level?: ButtonLevel;
iconLabel?: string;
className?: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onClick?: Function;
@@ -219,7 +220,7 @@ const Button = React.forwardRef((props: Props, ref: any) => {
function renderIcon() {
if (!props.iconName) return null;
return <StyledIcon animation={props.iconAnimation} mr={iconOnly ? '0' : '6px'} color={props.color} className={props.iconName}/>;
return <StyledIcon aria-label={props.iconLabel} animation={props.iconAnimation} mr={iconOnly ? '0' : '6px'} color={props.color} className={props.iconName}/>;
}
function renderTitle() {

View File

@@ -4,7 +4,7 @@ import ButtonBar from './ButtonBar';
import Button, { ButtonLevel, ButtonSize } from '../Button/Button';
import { _ } from '@joplin/lib/locale';
import bridge from '../../services/bridge';
import Setting, { AppType, SyncStartupOperation } from '@joplin/lib/models/Setting';
import Setting, { AppType, SettingItemSubType, SyncStartupOperation } from '@joplin/lib/models/Setting';
import control_PluginsStates from './controls/plugins/PluginsStates';
import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen';
import { reg } from '@joplin/lib/registry';
@@ -20,12 +20,23 @@ import ToggleAdvancedSettingsButton from './controls/ToggleAdvancedSettingsButto
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
import FontSearch from './FontSearch';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const settingKeyToControl: any = {
'plugins.states': control_PluginsStates,
};
interface Font {
family: string;
}
declare global {
interface Window {
queryLocalFonts(): Promise<Font[]>;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
class ConfigScreenComponent extends React.Component<any, any> {
@@ -44,6 +55,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
screenName: '',
changedSettingKeys: [],
needRestart: false,
fonts: [],
};
this.rowStyle_ = {
@@ -78,12 +90,16 @@ class ConfigScreenComponent extends React.Component<any, any> {
this.setState({ settings: this.props.settings });
}
public componentDidMount() {
public async componentDidMount() {
if (this.props.defaultSection) {
this.setState({ selectedSectionName: this.props.defaultSection }, () => {
void this.switchSection(this.props.defaultSection);
});
}
const fonts = (await window.queryLocalFonts()).map((font: Font) => font.family);
const uniqueFonts = [...new Set(fonts)];
this.setState({ fonts: uniqueFonts });
}
private async handleSettingButton(key: string) {
@@ -591,22 +607,32 @@ class ConfigScreenComponent extends React.Component<any, any> {
const onTextChange = (event: any) => {
updateSettingValue(key, event.target.value);
};
return (
<div key={key} style={rowStyle}>
<div style={labelStyle}>
<label>{md.label()}</label>
</div>
<input
type={inputType}
style={inputStyle}
value={this.state.settings[key]}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onChange={(event: any) => {
onTextChange(event);
}}
spellCheck={false}
/>
{
md.subType === SettingItemSubType.FontFamily || md.subType === SettingItemSubType.MonospaceFontFamily ?
<FontSearch
type={inputType}
style={inputStyle}
value={this.state.settings[key]}
availableFonts={this.state.fonts}
onChange={fontFamily => updateSettingValue(key, fontFamily)}
subtype={md.subType}
/> :
<input
type={inputType}
style={inputStyle}
value={this.state.settings[key]}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onChange={(event: any) => {
onTextChange(event);
}}
spellCheck={false}
/>
}
<div style={{ width: inputStyle.width, minWidth: inputStyle.minWidth }}>
{descriptionComp}
</div>

View File

@@ -0,0 +1,232 @@
import React = require('react');
import { useMemo, useState, useCallback, CSSProperties, useEffect, useRef } from 'react';
import { _ } from '@joplin/lib/locale';
import { SettingItemSubType } from '@joplin/lib/models/Setting';
import { focus } from '@joplin/lib/utils/focusHandler';
interface Props {
type: string;
style: CSSProperties;
value: string;
availableFonts: string[];
onChange: (font: string)=> void;
subtype: string;
}
const FontSearch = (props: Props) => {
const { type, style, value, availableFonts, onChange, subtype } = props;
const [filteredAvailableFonts, setFilteredAvailableFonts] = useState(availableFonts);
const [inputText, setInputText] = useState(value);
const [showList, setShowList] = useState(false);
const [isListHovered, setIsListHovered] = useState(false);
const [isFontSelected, setIsFontSelected] = useState(value !== '');
const [visibleFonts, setVisibleFonts] = useState<string[]>([]);
const [isMonoBoxChecked, setIsMonoBoxChecked] = useState(false);
const isLoadingFonts = filteredAvailableFonts.length === 0;
const fontInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (subtype === SettingItemSubType.MonospaceFontFamily) {
setIsMonoBoxChecked(true);
}
}, [subtype]);
useEffect(() => {
if (!isMonoBoxChecked) return setFilteredAvailableFonts(availableFonts);
const localMonospacedFonts = availableFonts.filter((font: string) =>
monospaceKeywords.some((word: string) => font.toLowerCase().includes(word)) ||
knownMonospacedFonts.includes(font.toLowerCase()),
);
setFilteredAvailableFonts(localMonospacedFonts);
}, [isMonoBoxChecked, availableFonts]);
const displayedFonts = useMemo(() => {
if (isFontSelected) return filteredAvailableFonts;
return filteredAvailableFonts.filter((font: string) =>
font.toLowerCase().startsWith(inputText.toLowerCase()),
);
}, [filteredAvailableFonts, inputText, isFontSelected]);
useEffect(() => {
setVisibleFonts(displayedFonts.slice(0, 20));
}, [displayedFonts]);
// Lazy loading
const handleListScroll: React.UIEventHandler<HTMLDivElement> = useCallback((event) => {
const scrollTop = (event.target as HTMLDivElement).scrollTop;
const scrollHeight = (event.target as HTMLDivElement).scrollHeight;
const clientHeight = (event.target as HTMLDivElement).clientHeight;
// Check if the user has scrolled to the bottom of the container
// A small buffer of 20 pixels is subtracted from the total scrollHeight to ensure new content starts loading slightly before the user reaches the absolute bottom, providing a smoother experience.
if (scrollTop + clientHeight >= scrollHeight - 20) {
// Load the next 20 fonts
const remainingFonts = displayedFonts.slice(visibleFonts.length, visibleFonts.length + 20);
setVisibleFonts([...visibleFonts, ...remainingFonts]);
}
}, [displayedFonts, visibleFonts]);
const handleTextChange: React.ChangeEventHandler<HTMLInputElement> = useCallback((event) => {
setIsFontSelected(false);
setInputText(event.target.value);
onChange(event.target.value);
}, [onChange]);
const handleFocus: React.FocusEventHandler<HTMLInputElement> = useCallback(() => setShowList(true), []);
const handleBlur: React.FocusEventHandler<HTMLInputElement> = useCallback(() => {
if (!isListHovered) {
setShowList(false);
}
}, [isListHovered]);
const handleFontClick: React.MouseEventHandler<HTMLDivElement> = useCallback((event) => {
const font = (event.target as HTMLDivElement).innerText;
setInputText(font);
setShowList(false);
onChange(font);
setIsFontSelected(true);
}, [onChange]);
const handleListHover: React.MouseEventHandler<HTMLDivElement> = useCallback(() => setIsListHovered(true), []);
const handleListLeave: React.MouseEventHandler<HTMLDivElement> = useCallback(() => setIsListHovered(false), []);
const handleMonoBoxCheck: React.ChangeEventHandler<HTMLInputElement> = useCallback(() => {
setIsMonoBoxChecked(!isMonoBoxChecked);
focus('FontSearch::fontInputRef', fontInputRef.current);
}, [isMonoBoxChecked]);
return (
<>
<input
type={type}
style={style}
value={inputText}
onChange={handleTextChange}
onFocus={handleFocus}
onBlur={handleBlur}
spellCheck={false}
ref={fontInputRef}
/>
<div
className={'font-search-list'}
style={{ display: showList ? 'block' : 'none' }}
onMouseEnter={handleListHover}
onMouseLeave={handleListLeave}
onScroll={handleListScroll}
>
{
isLoadingFonts ? <div>{_('Loading...')}</div> :
<>
<div className='monospace-checkbox'>
<input
type='checkbox'
checked={isMonoBoxChecked}
onChange={handleMonoBoxCheck}
id={`show-monospace-fonts_${subtype}`}
/>
<label htmlFor={`show-monospace-fonts_${subtype}`}>{_('Show monospace fonts only.')}</label>
</div>
{
visibleFonts.map((font: string) =>
<div
key={font}
style={{ fontFamily: `"${font}"` }}
onClick={handleFontClick}
className='font-search-item'
>
{font}
</div>,
)
}
</>
}
</div>
</>
);
};
export default FontSearch;
// Known monospaced fonts from wikipedia
// https://en.wikipedia.org/wiki/List_of_monospaced_typefaces
// https://en.wikipedia.org/wiki/Category:Monospaced_typefaces
// Make sure to add the fonts in lower case
// cSpell:disable
const knownMonospacedFonts = [
'andalé mono',
'anonymous pro',
'bitstream vera sans mono',
'cascadia code',
'century schoolbook monospace',
'comic mono',
'computer modern mono/typewriter',
'consolas',
'courier',
'courier final draft',
'courier new',
'courier prime',
'courier screenplay',
'cousine',
'dejavu sans mono',
'droid sans mono',
'envy code r',
'everson mono',
'fantasque sans mono',
'fira code',
'fira mono',
'fixed',
'fixedsys',
'freemono',
'go mono',
'hack',
'hyperfont',
'ibm courier',
'ibm plex mono',
'inconsolata',
'input',
'iosevka',
'jetbrains mono',
'juliamono',
'letter gothic',
'liberation mono',
'lucida console',
'menlo',
'monaco',
'monofur',
'monospace (unicode)',
'nimbus mono l',
'nk57 monospace',
'noto mono',
'ocr-a',
'ocr-b',
'operator mono',
'overpass mono',
'oxygen mono',
'pragmatapro',
'profont',
'pt mono',
'recursive mono',
'roboto mono',
'sf mono',
'source code pro',
'spleen',
'terminal',
'terminus',
'tex gyre cursor',
'ubuntu mono',
'victor mono',
'wumpus mono',
];
const monospaceKeywords = [
'mono',
'code',
'courier',
'console',
'source code',
'terminal',
'fixed',
];

View File

@@ -12,3 +12,35 @@
.config-screen-content > .section:last-child {
border-bottom: 0;
}
.font-search-list {
background-color: var(--joplin-background-color);
max-height: 200px;
width: 50%;
min-width: 20em;
overflow-y: auto;
border: 1px solid var(--joplin-border-color4);
border-radius: 5px;
display: none;
margin-top: 10px;
}
.font-search-list > div {
padding: 5px;
}
.font-search-item {
border-bottom: 1px solid var(--joplin-border-color4);
cursor: pointer;
}
.font-search-item:hover {
color: var(--joplin-background-color);
background-color: var(--joplin-color);
}
.monospace-checkbox {
background-color: var(--joplin-background-color3);
display: flex;
align-items: center;
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useCallback, useState, useRef, useEffect } from 'react';
import { useCallback, useState, useRef, useEffect, useId } from 'react';
import { _ } from '@joplin/lib/locale';
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
import Dialog from '../Dialog';
@@ -127,13 +127,14 @@ export default function(props: Props) {
}
}, []);
const formTitleInputId = useId();
function renderForm() {
return (
<div>
<div className="form">
<div className="form-input-group">
<label>{_('Title')}</label>
<StyledInput type="text" ref={titleInputRef} value={folderTitle} onChange={onFolderTitleChange}/>
<label htmlFor={formTitleInputId}>{_('Title')}</label>
<StyledInput id={formTitleInputId} type="text" ref={titleInputRef} value={folderTitle} onChange={onFolderTitleChange}/>
</div>
<div className="form-input-group">

View File

@@ -1,19 +1,15 @@
import * as React from 'react';
import { DragEventHandler, KeyboardEventHandler, UIEventHandler } from 'react';
interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
interface Props<ItemType> {
style: React.CSSProperties & { height: number };
itemHeight: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
items: any[];
items: ItemType[];
disabled?: boolean;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onKeyDown?: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
itemRenderer: Function;
onKeyDown?: KeyboardEventHandler<HTMLElement>;
itemRenderer: (item: ItemType, index: number)=> React.JSX.Element;
className?: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onNoteDrop?: Function;
onItemDrop?: DragEventHandler<HTMLElement>;
}
interface State {
@@ -21,13 +17,12 @@ interface State {
bottomItemIndex: number;
}
class ItemList extends React.Component<Props, State> {
class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
private scrollTop_: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private listRef: any;
private listRef: React.MutableRefObject<HTMLDivElement>;
public constructor(props: Props) {
public constructor(props: Props<ItemType>) {
super(props);
this.scrollTop_ = 0;
@@ -39,12 +34,12 @@ class ItemList extends React.Component<Props, State> {
this.onDrop = this.onDrop.bind(this);
}
public visibleItemCount(props: Props = undefined) {
public visibleItemCount(props: Props<ItemType> = undefined) {
if (typeof props === 'undefined') props = this.props;
return Math.ceil(props.style.height / props.itemHeight);
}
public updateStateItemIndexes(props: Props = undefined) {
public updateStateItemIndexes(props: Props<ItemType> = undefined) {
if (typeof props === 'undefined') props = this.props;
const topItemIndex = Math.floor(this.scrollTop_ / props.itemHeight);
@@ -67,35 +62,47 @@ class ItemList extends React.Component<Props, State> {
return this.scrollTop_;
}
public get container() {
return this.listRef.current;
}
public UNSAFE_componentWillMount() {
this.updateStateItemIndexes();
}
public UNSAFE_componentWillReceiveProps(newProps: Props) {
public UNSAFE_componentWillReceiveProps(newProps: Props<ItemType>) {
this.updateStateItemIndexes(newProps);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public onScroll(event: any) {
this.scrollTop_ = event.target.scrollTop;
public onScroll: UIEventHandler<HTMLDivElement> = event => {
this.scrollTop_ = (event.target as HTMLElement).scrollTop;
this.updateStateItemIndexes();
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public onKeyDown(event: any) {
public onKeyDown: KeyboardEventHandler<HTMLElement> = event => {
if (this.props.onKeyDown) this.props.onKeyDown(event);
};
public onDrop: DragEventHandler<HTMLElement> = event => {
if (this.props.onItemDrop) this.props.onItemDrop(event);
};
public get firstVisibleIndex() {
return Math.min(this.props.items.length - 1, this.state.topItemIndex);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public onDrop(event: any) {
if (this.props.onNoteDrop) this.props.onNoteDrop(event);
public get lastVisibleIndex() {
return Math.max(0, this.state.bottomItemIndex);
}
public isIndexVisible(itemIndex: number) {
return itemIndex >= this.firstVisibleIndex && itemIndex <= this.lastVisibleIndex;
}
public makeItemIndexVisible(itemIndex: number) {
const top = Math.min(this.props.items.length - 1, this.state.topItemIndex);
const bottom = Math.max(0, this.state.bottomItemIndex);
if (this.isIndexVisible(itemIndex)) return;
if (itemIndex >= top && itemIndex <= bottom) return;
const top = this.firstVisibleIndex;
let scrollTop = 0;
if (itemIndex < top) {
@@ -130,8 +137,11 @@ class ItemList extends React.Component<Props, State> {
public render() {
const items = this.props.items;
const style = { ...this.props.style, overflowX: 'hidden',
overflowY: 'auto' };
const style: React.CSSProperties = {
...this.props.style,
overflowX: 'hidden',
overflowY: 'auto',
};
// if (this.props.disabled) style.opacity = 0.5;

View File

@@ -1,3 +1,12 @@
.inbox-email-value {
font-weight: bold;
}
.alert-warn {
background-color: var(--joplin-background-color);
width: fit-content;
p {
padding: calc(var(--joplin-font-size) * 0.8);
}
}

View File

@@ -3,9 +3,11 @@ import { AppState } from '../app.reducer';
import { _ } from '@joplin/lib/locale';
import { clipboard } from 'electron';
import Button from './Button/Button';
import { Fragment } from 'react';
type JoplinCloudConfigScreenProps = {
inboxEmail: string;
joplinCloudAccountType: number;
};
const JoplinCloudConfigScreen = (props: JoplinCloudConfigScreenProps) => {
@@ -13,12 +15,21 @@ const JoplinCloudConfigScreen = (props: JoplinCloudConfigScreenProps) => {
clipboard.writeText(props.inboxEmail);
};
const isEmailToNoteAvailableInAccount = props.joplinCloudAccountType !== 1;
return (
<div>
<h2>{_('Email to note')}</h2>
<p>{_('Any email sent to this address will be converted into a note and added to your collection. The note will be saved into the Inbox notebook')}</p>
<p className='inbox-email-value'>{props.inboxEmail}</p>
<Button onClick={copyToClipboard} title={_('Copy to clipboard')} />
{
isEmailToNoteAvailableInAccount ? <Fragment>
<p className='inbox-email-value'>{props.inboxEmail}</p>
<Button onClick={copyToClipboard} title={_('Copy to clipboard')} />
</Fragment>
: <div className='alert-warn'>
<p>{_('Your account doesn\'t have access to this feature')}</p>
</div>
}
</div>
);
};
@@ -26,6 +37,7 @@ const JoplinCloudConfigScreen = (props: JoplinCloudConfigScreenProps) => {
const mapStateToProps = (state: AppState) => {
return {
inboxEmail: state.settings['sync.10.inboxEmail'],
joplinCloudAccountType: state.settings['sync.10.accountType'],
};
};

View File

@@ -1,4 +1,4 @@
// Helper commands added to the the CodeMirror instance
// Helper commands added to the CodeMirror instance
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export default function useJoplinCommands(CodeMirror: any) {

View File

@@ -36,7 +36,7 @@ import { focus } from '@joplin/lib/utils/focusHandler';
const md5 = require('md5');
const { clipboard } = require('electron');
const supportedLocales = require('./supportedLocales');
import { isLink } from '@joplin/utils/url';
import { hasProtocol } from '@joplin/utils/url';
const logger = Logger.create('TinyMCE');
@@ -1195,7 +1195,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
editor.insertContent(result.html);
}
} else {
if (BaseItem.isMarkdownTag(pastedText) || isLink(pastedText)) { // Paste a link to a note
if (BaseItem.isMarkdownTag(pastedText) || hasProtocol(pastedText, ['https', 'joplin', 'file'])) { // Paste a link to a note
logger.info('onPaste: pasting as a Markdown tag');
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, pastedText, markupRenderOptions({ bodyOnly: true }));
editor.insertContent(result.html);

View File

@@ -95,7 +95,7 @@ export default function openEditDialog(editor: any, markupToHtml: any, dispatchD
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onSubmit: async (dialogApi: any) => {
const newSource = newBlockSource(dialogApi.getData().languageInput, dialogApi.getData().codeTextArea, source);
const md = `${newSource.openCharacters}${newSource.content.trim()}${newSource.closeCharacters}`;
const md = `${newSource.openCharacters}${newSource.content}${newSource.closeCharacters}`;
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true });
// markupToHtml will return the complete editable HTML, but we only

View File

@@ -5,16 +5,18 @@ import bridge from '../../../services/bridge';
import { ContextMenuItemType, ContextMenuOptions, ContextMenuItems, resourceInfo, textToDataUri, svgUriToPng, svgDimensions } from './contextMenuUtils';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
import Resource from '@joplin/lib/models/Resource';
import Resource, { resourceOcrStatusToString } from '@joplin/lib/models/Resource';
import BaseItem from '@joplin/lib/models/BaseItem';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import { processPastedHtml } from './resourceHandling';
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import { NoteEntity, ResourceEntity, ResourceOcrStatus } from '@joplin/lib/services/database/types';
import { TinyMceEditorEvents } from '../NoteBody/TinyMCE/utils/types';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import Setting from '@joplin/lib/models/Setting';
import ItemChange from '@joplin/lib/models/ItemChange';
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from './types';
import shim from '@joplin/lib/shim';
import { openFileWithExternalEditor } from '@joplin/lib/services/ExternalEditWatcher/utils';
const fs = require('fs-extra');
const { writeFile } = require('fs-extra');
const { clipboard } = require('electron');
@@ -135,6 +137,21 @@ export function menuItems(dispatch: Function, htmlToMd: HtmlToMarkdownHandler, m
},
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
},
copyOcrText: {
label: _('View OCR text'),
onAction: async (options: ContextMenuOptions) => {
const { resource } = await resourceInfo(options);
if (resource.ocr_status === ResourceOcrStatus.Done) {
const tempFilePath = `${Setting.value('tempDir')}/${resource.id}_ocr.txt`;
await shim.fsDriver().writeFile(tempFilePath, resource.ocr_text, 'utf8');
await openFileWithExternalEditor(tempFilePath, bridge());
} else {
bridge().showInfoMessageBox(_('This attachment does not have OCR data (Status: %s)', resourceOcrStatusToString(resource.ocr_status)));
}
},
isActive: (itemType: ContextMenuItemType, _options: ContextMenuOptions) => itemType === ContextMenuItemType.Resource,
},
copyPathToClipboard: {
label: _('Copy path to clipboard'),
onAction: async (options: ContextMenuOptions) => {

View File

@@ -1,5 +1,4 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import { useMemo, useRef, useEffect } from 'react';
import { AppState } from '../../app.reducer';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
@@ -22,6 +21,7 @@ import * as focusElementNoteList from './commands/focusElementNoteList';
import CommandService from '@joplin/lib/services/CommandService';
import useDragAndDrop from './utils/useDragAndDrop';
import { itemIsInTrash } from '@joplin/lib/services/trash';
import getEmptyFolderMessage from '@joplin/lib/components/shared/NoteList/getEmptyFolderMessage';
import Folder from '@joplin/lib/models/Folder';
const { connect } = require('react-redux');
@@ -187,7 +187,7 @@ const NoteList = (props: Props) => {
const renderEmptyList = () => {
if (props.notes.length) return null;
return <div className="emptylist">{props.folders.length ? _('No notes in here. Create one by clicking on "New note".') : _('There is currently no notebook. Create one by clicking on "New notebook".')}</div>;
return <div className="emptylist">{getEmptyFolderMessage(props.folders, props.selectedFolderId)}</div>;
};
const renderFiller = (key: string, style: React.CSSProperties) => {

View File

@@ -187,6 +187,8 @@ function NoteListControls(props: Props) {
}
function sortOrderFieldIcon() {
const defaultIcon = 'fas fa-cog';
const field = props.sortOrderField;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const iconMap: any = {
@@ -194,8 +196,10 @@ function NoteListControls(props: Props) {
user_created_time: 'far fa-calendar-plus',
title: 'fas fa-font',
order: 'fas fa-wrench',
todo_due: 'fas fa-calendar-check',
todo_completed: 'fas fa-check',
};
return `${iconMap[field] || iconMap['title']} ${field}`;
return `${iconMap[field] || defaultIcon} ${field}`;
}
function sortOrderReverseIcon() {

View File

@@ -0,0 +1,100 @@
import * as React from 'react';
import { AppState } from '../../app.reducer';
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { useMemo, useRef, useState } from 'react';
import ItemList from '../ItemList';
import useElementHeight from '../hooks/useElementHeight';
import useSidebarListData from './hooks/useSidebarListData';
import useSelectedSidebarIndex from './hooks/useSelectedSidebarIndex';
import useOnSidebarKeyDownHandler from './hooks/useOnSidebarKeyDownHandler';
import useFocusHandler from './hooks/useFocusHandler';
import useOnRenderItem from './hooks/useOnRenderItem';
import { ListItem } from './types';
import useSidebarCommandHandler from './hooks/useSidebarCommandHandler';
interface Props {
dispatch: Dispatch;
themeId: number;
plugins: PluginStates;
tags: TagsWithNoteCountEntity[];
folders: FolderEntity[];
notesParentType: string;
selectedTagId: string;
selectedFolderId: string;
selectedSmartFilterId: string;
collapsedFolderIds: string[];
folderHeaderIsExpanded: boolean;
tagHeaderIsExpanded: boolean;
}
const FolderAndTagList: React.FC<Props> = props => {
const listItems = useSidebarListData(props);
const { selectedIndex, updateSelectedIndex } = useSelectedSidebarIndex({
...props,
listItems: listItems,
});
const [selectedListElement, setSelectedListElement] = useState<HTMLElement|null>(null);
const onRenderItem = useOnRenderItem({
...props,
selectedIndex,
onSelectedElementShown: setSelectedListElement,
});
const onKeyEventHandler = useOnSidebarKeyDownHandler({
dispatch: props.dispatch,
listItems: listItems,
selectedIndex,
updateSelectedIndex,
});
const itemListRef = useRef<ItemList<ListItem>>();
const { focusSidebar } = useFocusHandler({ itemListRef, selectedListElement, selectedIndex, listItems });
useSidebarCommandHandler({ focusSidebar });
const [itemListContainer, setItemListContainer] = useState<HTMLDivElement|null>(null);
const listHeight = useElementHeight(itemListContainer);
const listStyle = useMemo(() => ({ height: listHeight }), [listHeight]);
return (
<div
className='folder-and-tag-list'
ref={setItemListContainer}
>
<ItemList
className='items'
ref={itemListRef}
style={listStyle}
items={listItems}
itemRenderer={onRenderItem}
onKeyDown={onKeyEventHandler}
itemHeight={30}
/>
</div>
);
};
const mapStateToProps = (state: AppState) => {
return {
themeId: state.settings.theme,
tags: state.tags,
folders: state.folders,
notesParentType: state.notesParentType,
selectedFolderId: state.selectedFolderId,
selectedTagId: state.selectedTagId,
collapsedFolderIds: state.collapsedFolderIds,
selectedSmartFilterId: state.selectedSmartFilterId,
plugins: state.pluginService.plugins,
tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded,
folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded,
};
};
export default connect(mapStateToProps)(FolderAndTagList);

View File

@@ -1,766 +1,28 @@
import * as React from 'react';
import { useEffect, useRef, useCallback, useMemo, DragEventHandler, MouseEventHandler, RefObject } from 'react';
import { StyledRoot, StyledAddButton, StyledShareIcon, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton, StyledSpanFix } from './styles';
import { StyledRoot, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles';
import { ButtonLevel } from '../Button/Button';
import CommandService from '@joplin/lib/services/CommandService';
import InteropService from '@joplin/lib/services/interop/InteropService';
import Synchronizer from '@joplin/lib/Synchronizer';
import Setting from '@joplin/lib/models/Setting';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import InteropServiceHelper from '../../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import { AppState } from '../../app.reducer';
import { ModelType } from '@joplin/lib/BaseModel';
import BaseModel from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
import Tag from '@joplin/lib/models/Tag';
import Logger from '@joplin/utils/Logger';
import { FolderEntity, FolderIcon, FolderIconType, TagEntity } from '@joplin/lib/services/database/types';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { StateDecryptionWorker, StateResourceFetcher, store } from '@joplin/lib/reducer';
import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService';
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import FolderIconBox from '../FolderIconBox';
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
import { RuntimeProps } from './commands/focusElementSideBar';
import { StateDecryptionWorker, StateResourceFetcher } from '@joplin/lib/reducer';
import { connect } from 'react-redux';
import { renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash';
import { focus } from '@joplin/lib/utils/focusHandler';
import { ThemeStyle, themeStyle } from '@joplin/lib/theme';
import { themeStyle } from '@joplin/lib/theme';
import { Dispatch } from 'redux';
import bridge from '../../services/bridge';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
import { substrWithEllipsis } from '@joplin/lib/string-utils';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
import { clipboard } from 'electron';
import FolderAndTagList from './FolderAndTagList';
const logger = Logger.create('Sidebar');
interface Props {
themeId: number;
dispatch: Dispatch;
folders: FolderEntity[];
collapsedFolderIds: string[];
notesParentType: string;
selectedFolderId: string;
selectedTagId: string;
selectedSmartFilterId: string;
decryptionWorker: StateDecryptionWorker;
resourceFetcher: StateResourceFetcher;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
syncReport: any;
tags: TagEntity[];
syncStarted: boolean;
plugins: PluginStates;
folderHeaderIsExpanded: boolean;
tagHeaderIsExpanded: boolean;
}
const commands = [
require('./commands/focusElementSideBar'),
];
interface ExpandIconProps {
themeId: number;
isExpanded: boolean;
isVisible: boolean;
}
function ExpandIcon(props: ExpandIconProps) {
const theme = themeStyle(props.themeId);
const style: React.CSSProperties = {
width: 16, maxWidth: 16, opacity: 0.5, fontSize: Math.round(theme.toolbarIconSize * 0.8), display: 'flex', justifyContent: 'center',
};
if (!props.isVisible) style.visibility = 'hidden';
return <i className={props.isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right'} style={style}></i>;
}
interface ExpandLinkProps {
themeId: number;
folderId: string;
hasChildren: boolean;
isExpanded: boolean;
onClick: MouseEventHandler<HTMLElement>;
}
function ExpandLink(props: ExpandLinkProps) {
return props.hasChildren ? (
<StyledExpandLink href="#" data-folder-id={props.folderId} onClick={props.onClick}>
<ExpandIcon themeId={props.themeId} isVisible={true} isExpanded={props.isExpanded}/>
</StyledExpandLink>
) : (
<StyledExpandLink><ExpandIcon themeId={props.themeId} isVisible={false} isExpanded={false}/></StyledExpandLink>
);
}
const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) {
const defaultFolderIcon: FolderIcon = {
dataUrl: '',
emoji: '',
name: 'far fa-folder',
type: FolderIconType.FontAwesome,
};
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox opacity={0.7} folderIcon={defaultFolderIcon}/></div>;
}
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox folderIcon={folderIcon}/></div>;
};
type ItemDragListener = DragEventHandler<HTMLElement>;
type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
type ItemClickListener = MouseEventHandler<HTMLElement>;
interface FolderItemProps {
themeId: number;
hasChildren: boolean;
showFolderIcon: boolean;
isExpanded: boolean;
parentId: string;
depth: number;
selected: boolean;
folderId: string;
folderTitle: string;
folderIcon: FolderIcon;
anchorRef: RefObject<HTMLElement>;
noteCount: number;
onFolderDragStart_: ItemDragListener;
onFolderDragOver_: ItemDragListener;
onFolderDrop_: ItemDragListener;
itemContextMenu: ItemContextMenuListener;
folderItem_click: (folderId: string)=> void;
onFolderToggleClick_: ItemClickListener;
shareId: string;
}
function FolderItem(props: FolderItemProps) {
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const noteCountComp = noteCount ? <StyledNoteCount className="note-count-label">{noteCount}</StyledNoteCount> : null;
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
const draggable = ![getTrashFolderId(), Folder.conflictFolderId()].includes(folderId);
const doRenderFolderIcon = () => {
if (folderId === getTrashFolderId()) {
return renderFolderIcon(getTrashFolderIcon(FolderIconType.FontAwesome));
}
if (!showFolderIcon) return null;
return renderFolderIcon(folderIcon);
};
return (
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={draggable} data-folder-id={folderId}>
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
<StyledListItemAnchor
ref={anchorRef}
className="list-item"
isConflictFolder={folderId === Folder.conflictFolderId()}
href="#"
selected={selected}
shareId={shareId}
data-id={folderId}
data-type={BaseModel.TYPE_FOLDER}
onContextMenu={itemContextMenu}
data-folder-id={folderId}
onClick={() => {
folderItem_click(folderId);
}}
onDoubleClick={onFolderToggleClick_}
>
{doRenderFolderIcon()}<StyledSpanFix className="title" style={{ lineHeight: 0 }}>{folderTitle}</StyledSpanFix>
{shareIcon} {noteCountComp}
</StyledListItemAnchor>
</StyledListItem>
);
}
const menuUtils = new MenuUtils(CommandService.instance());
const SidebarComponent = (props: Props) => {
const folderItemsOrder_ = useRef<string[]>();
folderItemsOrder_.current = [];
const tagItemsOrder_ = useRef<string[]>();
tagItemsOrder_.current = [];
const rootRef = useRef(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const anchorItemRefs = useRef<Record<string, any>>({});
// This whole component is a bit of a mess and rather than passing
// a plugins prop around, not knowing how it's going to affect
// re-rendering, we just keep a ref to it. Currently that's enough
// as plugins are only accessed from context menus. However if want
// to do more complex things with plugins in the sidebar, it will
// probably have to be refactored using React Hooks first.
const pluginsRef = useRef<PluginStates>(null);
pluginsRef.current = props.plugins;
// If at least one of the folder has an icon, then we display icons for all
// folders (those without one will get the default icon). This is so that
// visual alignment is correct for all folders, otherwise the folder tree
// looks messy.
const showFolderIcons = useMemo(() => {
return Folder.shouldShowFolderIcons(props.folders);
}, [props.folders]);
const getSelectedItem = useCallback(() => {
if (props.notesParentType === 'Folder' && props.selectedFolderId) {
return { type: 'folder', id: props.selectedFolderId };
} else if (props.notesParentType === 'Tag' && props.selectedTagId) {
return { type: 'tag', id: props.selectedTagId };
}
return null;
}, [props.notesParentType, props.selectedFolderId, props.selectedTagId]);
const getFirstAnchorItemRef = useCallback((type: string) => {
const refs = anchorItemRefs.current[type];
if (!refs) return null;
const p = type === 'folder' ? props.folders : props.tags;
const item = p && p.length ? p[0] : null;
if (!item) return null;
return refs[item.id];
}, [anchorItemRefs, props.folders, props.tags]);
useEffect(() => {
const runtimeProps: RuntimeProps = {
getSelectedItem,
anchorItemRefs,
getFirstAnchorItemRef,
};
CommandService.instance().componentRegisterCommands(runtimeProps, commands);
return () => {
CommandService.instance().componentUnregisterCommands(commands);
};
}, [
getSelectedItem,
anchorItemRefs,
getFirstAnchorItemRef,
]);
const onFolderDragStart_: ItemDragListener = useCallback(event => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
if (!folderId) return;
event.dataTransfer.setDragImage(new Image(), 1, 1);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify([folderId]));
}, []);
const onFolderDragOver_: ItemDragListener = useCallback(event => {
if (event.dataTransfer.types.indexOf('text/x-jop-note-ids') >= 0) event.preventDefault();
if (event.dataTransfer.types.indexOf('text/x-jop-folder-ids') >= 0) event.preventDefault();
}, []);
const onFolderDrop_: ItemDragListener = useCallback(async event => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
const dt = event.dataTransfer;
if (!dt) return;
// folderId can be NULL when dropping on the sidebar Notebook header. In that case, it's used
// to put the dropped folder at the root. But for notes, folderId needs to always be defined
// since there's no such thing as a root note.
try {
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
event.preventDefault();
if (!folderId) return;
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
await onFolderDrop(noteIds, [], folderId);
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
event.preventDefault();
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
await onFolderDrop([], folderIds, folderId);
}
} catch (error) {
logger.error(error);
alert(error.message);
}
}, []);
const onTagDrop_: ItemDragListener = useCallback(async event => {
const tagId = event.currentTarget.getAttribute('data-tag-id');
const dt = event.dataTransfer;
if (!dt) return;
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
event.preventDefault();
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
for (let i = 0; i < noteIds.length; i++) {
await Tag.addNote(tagId, noteIds[i]);
}
}
}, []);
const onFolderToggleClick_: ItemClickListener = useCallback(event => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
props.dispatch({
type: 'FOLDER_TOGGLE',
id: folderId,
});
}, [props.dispatch]);
const header_contextMenu = useCallback(async () => {
const menu = new Menu();
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder')),
);
menu.popup({ window: bridge().window() });
}, []);
const itemContextMenu: ItemContextMenuListener = useCallback(async event => {
const itemId = event.currentTarget.getAttribute('data-id');
if (itemId === Folder.conflictFolderId()) return;
const itemType = Number(event.currentTarget.getAttribute('data-type'));
if (!itemId || !itemType) throw new Error('No data on element');
const state: AppState = store().getState();
let deleteMessage = '';
const deleteButtonLabel = _('Remove');
if (itemType === BaseModel.TYPE_TAG) {
const tag = await Tag.load(itemId);
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
} else if (itemType === BaseModel.TYPE_SEARCH) {
deleteMessage = _('Remove this search from the sidebar?');
}
const menu = new Menu();
if (itemId === getTrashFolderId()) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('emptyTrash')),
);
menu.popup({ window: bridge().window() });
return;
}
let item = null;
if (itemType === BaseModel.TYPE_FOLDER) {
item = BaseModel.byId(props.folders, itemId);
}
const isDeleted = item ? !!item.deleted_time : false;
if (!isDeleted) {
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
);
}
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
);
} else {
menu.append(
new MenuItem({
label: deleteButtonLabel,
click: async () => {
const ok = bridge().showConfirmMessageBox(deleteMessage, {
buttons: [deleteButtonLabel, _('Cancel')],
defaultId: 1,
});
if (!ok) return;
if (itemType === BaseModel.TYPE_TAG) {
await Tag.untagAll(itemId);
} else if (itemType === BaseModel.TYPE_SEARCH) {
props.dispatch({
type: 'SEARCH_DELETE',
id: itemId,
});
}
},
}),
);
}
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
menu.append(new MenuItem({ type: 'separator' }));
const exportMenu = new Menu();
const ioService = InteropService.instance();
const ioModules = ioService.modules();
for (let i = 0; i < ioModules.length; i++) {
const module = ioModules[i];
if (module.type !== 'exporter') continue;
exportMenu.append(
new MenuItem({
label: module.fullLabel(),
click: async () => {
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
},
}),
);
}
// We don't display the "Share notebook" menu item for sub-notebooks
// that are within a shared notebook. If user wants to do this,
// they'd have to move the notebook out of the shared notebook
// first.
const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId });
if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
}
if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId)));
}
menu.append(
new MenuItem({
label: _('Export'),
submenu: exportMenu,
}),
);
if (Setting.value('notes.perFolderSortOrderEnabled')) {
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId),
type: 'checkbox',
checked: PerFolderSortOrderService.isSet(itemId),
}));
}
}
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem({
label: _('Copy external link'),
click: () => {
clipboard.writeText(getFolderCallbackUrl(itemId));
},
}),
);
}
if (itemType === BaseModel.TYPE_TAG) {
menu.append(new MenuItem(
menuUtils.commandToStatefulMenuItem('renameTag', itemId),
));
menu.append(
new MenuItem({
label: _('Copy external link'),
click: () => {
clipboard.writeText(getTagCallbackUrl(itemId));
},
}),
);
}
const pluginViews = pluginUtils.viewsByType(pluginsRef.current, 'menuItem');
for (const view of pluginViews) {
const location = view.location;
if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu ||
itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu
) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem(view.commandName, itemId)),
);
}
}
} else {
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', itemId)),
);
}
}
menu.popup({ window: bridge().window() });
}, [props.folders, props.dispatch, pluginsRef]);
const folderItem_click = useCallback((folderId: string) => {
props.dispatch({
type: 'FOLDER_SELECT',
id: folderId ? folderId : null,
});
}, [props.dispatch]);
const tagItem_click = useCallback((tag: TagEntity|undefined) => {
props.dispatch({
type: 'TAG_SELECT',
id: tag ? tag.id : null,
});
}, [props.dispatch]);
const onHeaderClick_ = useCallback((key: string) => {
const isExpanded = key === 'tagHeader' ? props.tagHeaderIsExpanded : props.folderHeaderIsExpanded;
Setting.setValue(key === 'tagHeader' ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded', !isExpanded);
}, [props.folderHeaderIsExpanded, props.tagHeaderIsExpanded]);
const onAllNotesClick_ = useCallback(() => {
props.dispatch({
type: 'SMART_FILTER_SELECT',
id: ALL_NOTES_FILTER_ID,
});
folderItem_click(ALL_NOTES_FILTER_ID);
}, [props.dispatch, folderItem_click]);
const anchorItemRef = (type: string, id: string) => {
if (!anchorItemRefs.current[type]) anchorItemRefs.current[type] = {};
if (anchorItemRefs.current[type][id]) return anchorItemRefs.current[type][id];
anchorItemRefs.current[type][id] = React.createRef();
return anchorItemRefs.current[type][id];
};
const renderNoteCount = (count: number) => {
return count ? <StyledNoteCount className="note-count-label">{count}</StyledNoteCount> : null;
};
const renderExpandIcon = (theme: ThemeStyle, isExpanded: boolean, isVisible: boolean) => {
const style: React.CSSProperties = {
width: 16, maxWidth: 16, opacity: 0.5, fontSize: Math.round(theme.toolbarIconSize * 0.8), display: 'flex', justifyContent: 'center',
};
if (!isVisible) style.visibility = 'hidden';
return <i className={isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right'} style={style}></i>;
};
const toggleAllNotesContextMenu = useCallback(() => {
const menu = new Menu();
if (Setting.value('notes.perFolderSortOrderEnabled')) {
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', ALL_NOTES_FILTER_ID),
type: 'checkbox',
checked: PerFolderSortOrderService.isSet(ALL_NOTES_FILTER_ID),
}));
}
menu.popup({ window: bridge().window() });
}, []);
const renderAllNotesItem = (theme: ThemeStyle, selected: boolean) => {
return (
<StyledListItem key="allNotesHeader" selected={selected} className={'list-item-container list-item-depth-0 all-notes'} isSpecialItem={true}>
<StyledExpandLink>{renderExpandIcon(theme, false, false)}</StyledExpandLink>
<StyledAllNotesIcon className="icon-notes"/>
<StyledListItemAnchor
className="list-item"
isSpecialItem={true}
href="#"
selected={selected}
onClick={onAllNotesClick_}
onContextMenu={toggleAllNotesContextMenu}
>
{_('All notes')}
</StyledListItemAnchor>
</StyledListItem>
);
};
const renderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) =>{
const anchorRef = anchorItemRef('folder', folder.id);
const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let noteCount = (folder as any).note_count;
// For now hide the count for folders in the trash because it doesn't work and getting it to
// work would be tricky.
if (folder.deleted_time || folder.id === getTrashFolderId()) noteCount = 0;
// Thunderbird count: Subtract children note_count from parent folder if it expanded.
if (isExpanded) {
for (let i = 0; i < props.folders.length; i++) {
if (props.folders[i].parent_id === folder.id) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteCount -= (props.folders[i] as any).note_count;
}
}
}
return <FolderItem
key={folder.id}
folderId={folder.id}
folderTitle={Folder.displayTitle(folder)}
folderIcon={Folder.unserializeIcon(folder.icon)}
themeId={props.themeId}
depth={depth}
selected={selected}
isExpanded={isExpanded}
hasChildren={hasChildren}
anchorRef={anchorRef}
noteCount={noteCount}
onFolderDragStart_={onFolderDragStart_}
onFolderDragOver_={onFolderDragOver_}
onFolderDrop_={onFolderDrop_}
itemContextMenu={itemContextMenu}
folderItem_click={folderItem_click}
onFolderToggleClick_={onFolderToggleClick_}
shareId={folder.share_id}
parentId={folder.parent_id}
showFolderIcon={showFolderIcons}
/>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const renderTag = (tag: any, selected: boolean) => {
const anchorRef = anchorItemRef('tag', tag.id);
let noteCount = null;
if (Setting.value('showNoteCounts')) {
if (Setting.value('showCompletedTodos')) noteCount = renderNoteCount(tag.note_count);
else noteCount = renderNoteCount(tag.note_count - tag.todo_completed_count);
}
return (
<StyledListItem selected={selected}
className={`list-item-container ${selected ? 'selected' : ''}`}
key={tag.id}
onDrop={onTagDrop_}
data-tag-id={tag.id}
>
<StyledExpandLink>{renderExpandIcon(theme, false, false)}</StyledExpandLink>
<StyledListItemAnchor
ref={anchorRef}
className="list-item"
href="#"
selected={selected}
data-id={tag.id}
data-type={BaseModel.TYPE_TAG}
onContextMenu={itemContextMenu}
onClick={() => {
tagItem_click(tag);
}}
>
<StyledSpanFix className="tag-label">{Tag.displayTitle(tag)}</StyledSpanFix>
{noteCount}
</StyledListItemAnchor>
</StyledListItem>
);
};
const renderHeader = (
key: string,
label: string,
iconName: string,
contextMenuHandler: ItemContextMenuListener|null = null,
onPlusButtonClick: ItemClickListener|null = null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
extraProps: any = {},
) => {
const headerClick = extraProps.onClick || null;
delete extraProps.onClick;
const ref = anchorItemRef('headers', key);
return (
<div key={key} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<StyledHeader
ref={ref}
{...extraProps}
onContextMenu={contextMenuHandler}
onClick={(event: MouseEvent) => {
// if a custom click event is attached, trigger that.
if (headerClick) {
headerClick(key, event);
}
onHeaderClick_(key);
}}
>
<StyledHeaderIcon className={iconName}/>
<StyledHeaderLabel>{label}</StyledHeaderLabel>
</StyledHeader>
{ onPlusButtonClick && <StyledAddButton onClick={onPlusButtonClick} iconName="fas fa-plus" level={ButtonLevel.SidebarSecondary}/> }
</div>
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onKeyDown = useCallback((event: KeyboardEvent) => {
const keyCode = event.keyCode;
const selectedItem = getSelectedItem();
if (keyCode === 40 || keyCode === 38) {
// DOWN / UP
event.preventDefault();
const focusItems = [];
for (let i = 0; i < folderItemsOrder_.current.length; i++) {
const id = folderItemsOrder_.current[i];
focusItems.push({ id: id, ref: anchorItemRefs.current['folder'][id], type: 'folder' });
}
for (let i = 0; i < tagItemsOrder_.current.length; i++) {
const id = tagItemsOrder_.current[i];
focusItems.push({ id: id, ref: anchorItemRefs.current['tag'][id], type: 'tag' });
}
let currentIndex = 0;
for (let i = 0; i < focusItems.length; i++) {
if (!selectedItem || focusItems[i].id === selectedItem.id) {
currentIndex = i;
break;
}
}
const inc = keyCode === 38 ? -1 : +1;
let newIndex = currentIndex + inc;
if (newIndex < 0) newIndex = 0;
if (newIndex > focusItems.length - 1) newIndex = focusItems.length - 1;
const focusItem = focusItems[newIndex];
const actionName = `${focusItem.type.toUpperCase()}_SELECT`;
props.dispatch({
type: actionName,
id: focusItem.id,
});
focus('SideBar::onKeyDown', focusItem.ref.current);
}
if (keyCode === 9) {
// TAB
event.preventDefault();
if (event.shiftKey) {
void CommandService.instance().execute('focusElement', 'noteBody');
} else {
void CommandService.instance().execute('focusElement', 'noteList');
}
}
if (selectedItem && selectedItem.type === 'folder' && keyCode === 32) {
// SPACE
event.preventDefault();
props.dispatch({
type: 'FOLDER_TOGGLE',
id: selectedItem.id,
});
}
if (keyCode === 65 && (event.ctrlKey || event.metaKey)) {
// Ctrl+A key
event.preventDefault();
}
}, [getSelectedItem, props.dispatch]);
const renderSynchronizeButton = (type: string) => {
const label = type === 'sync' ? _('Synchronise') : _('Cancel');
const iconAnimation = type !== 'sync' ? 'icon-infinite-rotation 1s linear infinite' : '';
@@ -779,61 +41,8 @@ const SidebarComponent = (props: Props) => {
);
};
const onAddFolderButtonClick = useCallback(() => {
void CommandService.instance().execute('newFolder');
}, []);
const theme = themeStyle(props.themeId);
const items = [];
items.push(
renderHeader('folderHeader', _('Notebooks'), 'icon-notebooks', header_contextMenu, onAddFolderButtonClick, {
onDrop: onFolderDrop_,
['data-folder-id']: '',
toggleblock: 1,
}),
);
const foldersStyle = useMemo(() => {
return { display: props.folderHeaderIsExpanded ? 'block' : 'none', paddingBottom: 10 };
}, [props.folderHeaderIsExpanded]);
if (props.folders.length) {
const allNotesSelected = props.selectedFolderId === ALL_NOTES_FILTER_ID;
const result = renderFolders(props, renderFolderItem);
const folderItems = [renderAllNotesItem(theme, allNotesSelected)].concat(result.items);
folderItemsOrder_.current = result.order;
items.push(
<div
className={`folders ${props.folderHeaderIsExpanded ? 'expanded' : ''}`}
key="folder_items"
style={foldersStyle}
>
{folderItems}
</div>,
);
}
items.push(
renderHeader('tagHeader', _('Tags'), 'icon-tags', null, null, {
toggleblock: 1,
}),
);
if (props.tags.length) {
const result = renderTags(props, renderTag);
const tagItems = result.items;
tagItemsOrder_.current = result.order;
items.push(
<div className="tags" key="tag_items" style={{ display: props.tagHeaderIsExpanded ? 'block' : 'none' }}>
{tagItems}
</div>,
);
}
let decryptionReportText = '';
if (props.decryptionWorker && props.decryptionWorker.state !== 'idle' && props.decryptionWorker.itemCount) {
decryptionReportText = _('Decrypting items: %d/%d', props.decryptionWorker.itemIndex + 1, props.decryptionWorker.itemCount);
@@ -865,8 +74,8 @@ const SidebarComponent = (props: Props) => {
);
return (
<StyledRoot ref={rootRef} onKeyDown={onKeyDown} className="sidebar">
<div style={{ flex: 1, overflowX: 'hidden', overflowY: 'auto' }}>{items}</div>
<StyledRoot className="sidebar">
<div style={{ flex: 1 }}><FolderAndTagList/></div>
<div style={{ flex: 0, padding: theme.mainPadding }}>
{syncReportComp}
{syncButton}
@@ -877,24 +86,16 @@ const SidebarComponent = (props: Props) => {
const mapStateToProps = (state: AppState) => {
return {
folders: state.folders,
tags: state.tags,
searches: state.searches,
syncStarted: state.syncStarted,
syncReport: state.syncReport,
selectedFolderId: state.selectedFolderId,
selectedTagId: state.selectedTagId,
selectedSearchId: state.selectedSearchId,
selectedSmartFilterId: state.selectedSmartFilterId,
notesParentType: state.notesParentType,
locale: state.settings.locale,
themeId: state.settings.theme,
collapsedFolderIds: state.collapsedFolderIds,
decryptionWorker: state.decryptionWorker,
resourceFetcher: state.resourceFetcher,
plugins: state.pluginService.plugins,
tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded,
folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded,
};
};

View File

@@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale';
import layoutItemProp from '../../ResizableLayout/utils/layoutItemProp';
import { AppState } from '../../../app.reducer';
import { focus } from '@joplin/lib/utils/focusHandler';
import { SidebarCommandRuntimeProps } from '../types';
export const declaration: CommandDeclaration = {
name: 'focusElementSideBar',
@@ -10,29 +10,13 @@ export const declaration: CommandDeclaration = {
parentLabel: () => _('Focus'),
};
export interface RuntimeProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
getSelectedItem(): any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
getFirstAnchorItemRef(type: string): any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
anchorItemRefs: any;
}
export const runtime = (props: RuntimeProps): CommandRuntime => {
export const runtime = (props: SidebarCommandRuntimeProps): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
const sidebarVisible = layoutItemProp((context.state as AppState).mainLayout, 'sideBar', 'visible');
if (sidebarVisible) {
const item = props.getSelectedItem();
if (item) {
const anchorRef = props.anchorItemRefs.current[item.type][item.id];
if (anchorRef) focus('focusElementSideBar1', anchorRef.current);
} else {
const anchorRef = props.getFirstAnchorItemRef('folder');
if (anchorRef) focus('focusElementSideBar2', anchorRef.current);
}
props.focusSidebar();
}
},

View File

@@ -0,0 +1,82 @@
import { MutableRefObject, RefObject, useCallback, useEffect, useMemo, useRef } from 'react';
import { ListItem } from '../types';
import ItemList from '../../ItemList';
import { focus } from '@joplin/lib/utils/focusHandler';
interface Props {
itemListRef: RefObject<ItemList<ListItem>>;
selectedListElement: HTMLElement|null;
selectedIndex: number;
listItems: ListItem[];
}
const useFocusAfterNextRenderHandler = (
shouldFocusAfterNextRender: MutableRefObject<boolean>,
selectedListElement: HTMLElement|null,
) => {
useEffect(() => {
if (!shouldFocusAfterNextRender.current || !selectedListElement) return;
focus('FolderAndTagList/useFocusHandler/afterRender', selectedListElement);
shouldFocusAfterNextRender.current = false;
}, [selectedListElement, shouldFocusAfterNextRender]);
};
const useRefocusOnSelectionChangeHandler = (
itemListRef: RefObject<ItemList<ListItem>>,
shouldFocusAfterNextRender: MutableRefObject<boolean>,
listItems: ListItem[],
selectedIndex: number,
) => {
// We keep track of the key to avoid scrolling unnecessarily. For example, when the
// selection's index changes because a notebook is expanded/collapsed, we don't necessarily
// want to scroll the selection into view.
const lastSelectedItemKey = useRef('');
const selectedItemKey = useMemo(() => {
if (selectedIndex >= 0 && selectedIndex < listItems.length) {
return listItems[selectedIndex].key;
} else {
// When nothing is selected, re-use the key from before.
// This prevents the view from scrolling when a dropdown containing the
// selection is closed, then opened again.
return lastSelectedItemKey.current;
}
}, [listItems, selectedIndex]);
lastSelectedItemKey.current = selectedItemKey;
const selectedIndexRef = useRef(selectedIndex);
selectedIndexRef.current = selectedIndex;
useEffect(() => {
if (!itemListRef.current || !selectedItemKey) return;
const hasFocus = !!itemListRef.current.container.querySelector(':scope :focus');
shouldFocusAfterNextRender.current = hasFocus;
if (hasFocus) {
itemListRef.current.makeItemIndexVisible(selectedIndexRef.current);
}
}, [selectedItemKey, itemListRef, shouldFocusAfterNextRender]);
};
const useFocusHandler = (props: Props) => {
const { itemListRef, selectedListElement, selectedIndex, listItems } = props;
// When set to true, when selectedListElement next changes, select it.
const shouldFocusAfterNextRender = useRef(false);
useRefocusOnSelectionChangeHandler(itemListRef, shouldFocusAfterNextRender, listItems, selectedIndex);
useFocusAfterNextRenderHandler(shouldFocusAfterNextRender, selectedListElement);
const focusSidebar = useCallback(() => {
if (!selectedListElement || !itemListRef.current.isIndexVisible(selectedIndex)) {
itemListRef.current.makeItemIndexVisible(selectedIndex);
shouldFocusAfterNextRender.current = true;
} else {
focus('FolderAndTagList/useFocusHandler/focusSidebar', selectedListElement);
}
}, [selectedListElement, selectedIndex, itemListRef]);
return { focusSidebar };
};
export default useFocusHandler;

View File

@@ -0,0 +1,421 @@
import * as React from 'react';
import { DragEventHandler, MouseEventHandler, useCallback, useMemo, useRef } from 'react';
import { ItemClickListener, ItemDragListener, ListItem, ListItemType } from '../types';
import TagItem, { TagLinkClickEvent } from '../listItemComponents/TagItem';
import { Dispatch } from 'redux';
import { clipboard } from 'electron';
import { getTrashFolderId } from '@joplin/lib/services/trash';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import Tag from '@joplin/lib/models/Tag';
import { _ } from '@joplin/lib/locale';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
import { AppState } from '../../../app.reducer';
import { store } from '@joplin/lib/reducer';
import Folder from '@joplin/lib/models/Folder';
import bridge from '../../../services/bridge';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import { FolderEntity } from '@joplin/lib/services/database/types';
import InteropService from '@joplin/lib/services/interop/InteropService';
import InteropServiceHelper from '../../../InteropServiceHelper';
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
import Setting from '@joplin/lib/models/Setting';
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import FolderItem from '../listItemComponents/FolderItem';
import Logger from '@joplin/utils/Logger';
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
import HeaderItem from '../listItemComponents/HeaderItem';
import AllNotesItem from '../listItemComponents/AllNotesItem';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const logger = Logger.create('useOnRenderItem');
interface Props {
dispatch: Dispatch;
themeId: number;
plugins: PluginStates;
folders: FolderEntity[];
collapsedFolderIds: string[];
selectedIndex: number;
onSelectedElementShown: (element: HTMLElement)=> void;
}
type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
const menuUtils = new MenuUtils(CommandService.instance());
const useOnRenderItem = (props: Props) => {
const pluginsRef = useRef<PluginStates>(null);
pluginsRef.current = props.plugins;
const foldersRef = useRef<FolderEntity[]>(null);
foldersRef.current = props.folders;
const tagItem_click = useCallback(({ tag }: TagLinkClickEvent) => {
props.dispatch({
type: 'TAG_SELECT',
id: tag ? tag.id : null,
});
}, [props.dispatch]);
const onTagDrop_: DragEventHandler<HTMLElement> = useCallback(async event => {
const tagId = event.currentTarget.getAttribute('data-tag-id');
const dt = event.dataTransfer;
if (!dt) return;
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
event.preventDefault();
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
for (let i = 0; i < noteIds.length; i++) {
await Tag.addNote(tagId, noteIds[i]);
}
}
}, []);
const onItemContextMenu: ItemContextMenuListener = useCallback(async event => {
const itemId = event.currentTarget.getAttribute('data-id');
if (itemId === Folder.conflictFolderId()) return;
const itemType = Number(event.currentTarget.getAttribute('data-type'));
if (!itemId || !itemType) throw new Error('No data on element');
const state: AppState = store().getState();
let deleteMessage = '';
const deleteButtonLabel = _('Remove');
if (itemType === BaseModel.TYPE_TAG) {
const tag = await Tag.load(itemId);
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
} else if (itemType === BaseModel.TYPE_SEARCH) {
deleteMessage = _('Remove this search from the sidebar?');
}
const menu = new Menu();
if (itemId === getTrashFolderId()) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('emptyTrash')),
);
menu.popup({ window: bridge().window() });
return;
}
let item = null;
if (itemType === BaseModel.TYPE_FOLDER) {
item = BaseModel.byId(foldersRef.current, itemId);
}
const isDeleted = item ? !!item.deleted_time : false;
if (!isDeleted) {
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
);
}
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
);
} else {
menu.append(
new MenuItem({
label: deleteButtonLabel,
click: async () => {
const ok = bridge().showConfirmMessageBox(deleteMessage, {
buttons: [deleteButtonLabel, _('Cancel')],
defaultId: 1,
});
if (!ok) return;
if (itemType === BaseModel.TYPE_TAG) {
await Tag.untagAll(itemId);
} else if (itemType === BaseModel.TYPE_SEARCH) {
props.dispatch({
type: 'SEARCH_DELETE',
id: itemId,
});
}
},
}),
);
}
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
menu.append(new MenuItem({ type: 'separator' }));
const exportMenu = new Menu();
const ioService = InteropService.instance();
const ioModules = ioService.modules();
for (let i = 0; i < ioModules.length; i++) {
const module = ioModules[i];
if (module.type !== 'exporter') continue;
exportMenu.append(
new MenuItem({
label: module.fullLabel(),
click: async () => {
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
},
}),
);
}
// We don't display the "Share notebook" menu item for sub-notebooks
// that are within a shared notebook. If user wants to do this,
// they'd have to move the notebook out of the shared notebook
// first.
const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId });
if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
}
if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId)));
}
menu.append(
new MenuItem({
label: _('Export'),
submenu: exportMenu,
}),
);
if (Setting.value('notes.perFolderSortOrderEnabled')) {
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId),
type: 'checkbox',
checked: PerFolderSortOrderService.isSet(itemId),
}));
}
}
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem({
label: _('Copy external link'),
click: () => {
clipboard.writeText(getFolderCallbackUrl(itemId));
},
}),
);
}
if (itemType === BaseModel.TYPE_TAG) {
menu.append(new MenuItem(
menuUtils.commandToStatefulMenuItem('renameTag', itemId),
));
menu.append(
new MenuItem({
label: _('Copy external link'),
click: () => {
clipboard.writeText(getTagCallbackUrl(itemId));
},
}),
);
}
const pluginViews = pluginUtils.viewsByType(pluginsRef.current, 'menuItem');
for (const view of pluginViews) {
const location = view.location;
if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu ||
itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu
) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem(view.commandName, itemId)),
);
}
}
} else {
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', itemId)),
);
}
}
menu.popup({ window: bridge().window() });
}, [props.dispatch, pluginsRef]);
const onFolderDragStart_: ItemDragListener = useCallback(event => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
if (!folderId) return;
event.dataTransfer.setDragImage(new Image(), 1, 1);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify([folderId]));
}, []);
const onFolderDragOver_: ItemDragListener = useCallback(event => {
if (event.dataTransfer.types.indexOf('text/x-jop-note-ids') >= 0) event.preventDefault();
if (event.dataTransfer.types.indexOf('text/x-jop-folder-ids') >= 0) event.preventDefault();
}, []);
const onFolderDrop_: ItemDragListener = useCallback(async event => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
const dt = event.dataTransfer;
if (!dt) return;
// folderId can be NULL when dropping on the sidebar Notebook header. In that case, it's used
// to put the dropped folder at the root. But for notes, folderId needs to always be defined
// since there's no such thing as a root note.
try {
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
event.preventDefault();
if (!folderId) return;
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
await onFolderDrop(noteIds, [], folderId);
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
event.preventDefault();
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
await onFolderDrop([], folderIds, folderId);
}
} catch (error) {
logger.error(error);
alert(error.message);
}
}, []);
const onFolderToggleClick_: ItemClickListener = useCallback(event => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
props.dispatch({
type: 'FOLDER_TOGGLE',
id: folderId,
});
}, [props.dispatch]);
const folderItem_click = useCallback((folderId: string) => {
props.dispatch({
type: 'FOLDER_SELECT',
id: folderId ? folderId : null,
});
}, [props.dispatch]);
// If at least one of the folder has an icon, then we display icons for all
// folders (those without one will get the default icon). This is so that
// visual alignment is correct for all folders, otherwise the folder tree
// looks messy.
const showFolderIcons = useMemo(() => {
return Folder.shouldShowFolderIcons(props.folders);
}, [props.folders]);
const selectedIndexRef = useRef(props.selectedIndex);
selectedIndexRef.current = props.selectedIndex;
return useCallback((item: ListItem, index: number) => {
const selected = props.selectedIndex === index;
const anchorRefCallback = selected ? (
(element: HTMLElement) => {
if (selectedIndexRef.current === index) {
props.onSelectedElementShown(element);
}
}
) : null;
if (item.kind === ListItemType.Tag) {
const tag = item.tag;
return <TagItem
key={item.key}
anchorRef={anchorRefCallback}
selected={selected}
onClick={tagItem_click}
onTagDrop={onTagDrop_}
onContextMenu={onItemContextMenu}
tag={tag}
/>;
} else if (item.kind === ListItemType.Folder) {
const folder = item.folder;
const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let noteCount = (folder as any).note_count;
// For now hide the count for folders in the trash because it doesn't work and getting it to
// work would be tricky.
if (folder.deleted_time || folder.id === getTrashFolderId()) noteCount = 0;
// Thunderbird count: Subtract children note_count from parent folder if it expanded.
if (isExpanded) {
for (let i = 0; i < props.folders.length; i++) {
if (props.folders[i].parent_id === folder.id) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteCount -= (props.folders[i] as any).note_count;
}
}
}
return <FolderItem
key={item.key}
anchorRef={anchorRefCallback}
selected={selected}
folderId={folder.id}
folderTitle={Folder.displayTitle(folder)}
folderIcon={Folder.unserializeIcon(folder.icon)}
depth={item.depth}
isExpanded={isExpanded}
hasChildren={item.hasChildren}
noteCount={noteCount}
onFolderDragStart_={onFolderDragStart_}
onFolderDragOver_={onFolderDragOver_}
onFolderDrop_={onFolderDrop_}
itemContextMenu={onItemContextMenu}
folderItem_click={folderItem_click}
onFolderToggleClick_={onFolderToggleClick_}
shareId={folder.share_id}
parentId={folder.parent_id}
showFolderIcon={showFolderIcons}
/>;
} else if (item.kind === ListItemType.Header) {
return <HeaderItem
key={item.id}
item={item}
anchorRef={anchorRefCallback}
onDrop={item.supportsFolderDrop ? onFolderDrop_ : null}
/>;
} else if (item.kind === ListItemType.AllNotes) {
return <AllNotesItem
key={item.key}
selected={selected}
anchorRef={anchorRefCallback}
/>;
} else if (item.kind === ListItemType.Spacer) {
return (
<a key={item.key} className='sidebar-spacer-item' ref={anchorRefCallback} aria-label={_('Spacer')}></a>
);
} else {
const exhaustivenessCheck: never = item;
return exhaustivenessCheck;
}
}, [
folderItem_click,
onFolderDragOver_,
onFolderDragStart_,
onFolderDrop_,
onFolderToggleClick_,
onItemContextMenu,
onTagDrop_,
props.collapsedFolderIds,
props.folders,
showFolderIcons,
tagItem_click,
props.selectedIndex,
props.onSelectedElementShown,
]);
};
export default useOnRenderItem;

View File

@@ -0,0 +1,51 @@
import { Dispatch } from 'redux';
import { ListItem, ListItemType, SetSelectedIndexCallback } from '../types';
import { KeyboardEventHandler, useCallback } from 'react';
import CommandService from '@joplin/lib/services/CommandService';
interface Props {
dispatch: Dispatch;
listItems: ListItem[];
selectedIndex: number;
updateSelectedIndex: SetSelectedIndexCallback;
}
const useOnSidebarKeyDownHandler = (props: Props) => {
const { updateSelectedIndex, listItems, selectedIndex, dispatch } = props;
return useCallback<KeyboardEventHandler<HTMLElement>>((event) => {
const selectedItem = listItems[selectedIndex];
if (selectedItem && selectedItem.kind === ListItemType.Folder && event.code === 'Space') {
event.preventDefault();
dispatch({
type: 'FOLDER_TOGGLE',
id: selectedItem.folder.id,
});
} else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
event.preventDefault();
}
let indexChange = 0;
if (event.code === 'ArrowUp') {
indexChange = -1;
} else if (event.code === 'ArrowDown') {
indexChange = 1;
} else if (event.code === 'Tab') {
event.preventDefault();
if (event.shiftKey) {
void CommandService.instance().execute('focusElement', 'noteBody');
} else {
void CommandService.instance().execute('focusElement', 'noteList');
}
}
if (indexChange !== 0) {
event.preventDefault();
updateSelectedIndex(selectedIndex + indexChange);
}
}, [selectedIndex, listItems, updateSelectedIndex, dispatch]);
};
export default useOnSidebarKeyDownHandler;

View File

@@ -0,0 +1,88 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ListItem, ListItemType } from '../types';
import { isFolderSelected, isTagSelected } from '@joplin/lib/components/shared/side-menu-shared';
import { Dispatch } from 'redux';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
interface Props {
dispatch: Dispatch;
listItems: ListItem[];
notesParentType: string;
selectedTagId: string;
selectedFolderId: string;
selectedSmartFilterId: string;
}
const useSelectedSidebarIndex = (props: Props) => {
const appStateSelectedIndex = useMemo(() => {
for (let i = 0; i < props.listItems.length; i++) {
const listItem = props.listItems[i];
let selected = false;
if (listItem.kind === ListItemType.AllNotes) {
selected = props.selectedSmartFilterId === ALL_NOTES_FILTER_ID && props.notesParentType === 'SmartFilter';
} else if (listItem.kind === ListItemType.Header || listItem.kind === ListItemType.Spacer) {
selected = false;
} else if (listItem.kind === ListItemType.Folder) {
selected = isFolderSelected(listItem.folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType });
} else if (listItem.kind === ListItemType.Tag) {
selected = isTagSelected(listItem.tag, { selectedTagId: props.selectedTagId, notesParentType: props.notesParentType });
} else {
const exhaustivenessCheck: never = listItem;
return exhaustivenessCheck;
}
if (selected) {
return i;
}
}
return -1;
}, [props.listItems, props.selectedFolderId, props.selectedTagId, props.selectedSmartFilterId, props.notesParentType]);
// Not all list items correspond with selectable Joplin folders/tags, but we want to
// be able to select them anyway. This is handled with selectedIndexOverride.
//
// When selectedIndexOverride >= 0, it corresponds to the index of a selected item with no
// specific note parent item (e.g. a header).
const [selectedIndexOverride, setSelectedIndexOverride] = useState(-1);
useEffect(() => {
setSelectedIndexOverride(-1);
}, [appStateSelectedIndex]);
const updateSelectedIndex = useCallback((newIndex: number) => {
if (newIndex < 0) {
newIndex = 0;
} else if (newIndex >= props.listItems.length) {
newIndex = props.listItems.length - 1;
}
const newItem = props.listItems[newIndex];
let newOverrideIndex = -1;
if (newItem.kind === ListItemType.AllNotes) {
props.dispatch({
type: 'SMART_FILTER_SELECT',
id: ALL_NOTES_FILTER_ID,
});
} else if (newItem.kind === ListItemType.Folder) {
props.dispatch({
type: 'FOLDER_SELECT',
id: newItem.folder.id,
});
} else if (newItem.kind === ListItemType.Tag) {
props.dispatch({
type: 'TAG_SELECT',
id: newItem.tag.id,
});
} else {
newOverrideIndex = newIndex;
}
setSelectedIndexOverride(newOverrideIndex);
}, [props.listItems, props.dispatch]);
const selectedIndex = selectedIndexOverride === -1 ? appStateSelectedIndex : selectedIndexOverride;
return { selectedIndex, updateSelectedIndex };
};
export default useSelectedSidebarIndex;

View File

@@ -0,0 +1,24 @@
import CommandService from '@joplin/lib/services/CommandService';
import { useEffect } from 'react';
import commands from '../commands';
import { SidebarCommandRuntimeProps } from '../types';
interface Props {
focusSidebar: ()=> void;
}
const useSidebarCommandHandler = ({ focusSidebar }: Props) => {
useEffect(() => {
const runtimeProps: SidebarCommandRuntimeProps = {
focusSidebar,
};
CommandService.instance().componentRegisterCommands(runtimeProps, commands);
return () => {
CommandService.instance().componentUnregisterCommands(commands);
};
}, [focusSidebar]);
};
export default useSidebarCommandHandler;

View File

@@ -0,0 +1,98 @@
import { useMemo } from 'react';
import { FolderListItem, HeaderId, HeaderListItem, ListItem, ListItemType, TagListItem } from '../types';
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import { renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
import { _ } from '@joplin/lib/locale';
import CommandService from '@joplin/lib/services/CommandService';
import Setting from '@joplin/lib/models/Setting';
interface Props {
tags: TagsWithNoteCountEntity[];
folders: FolderEntity[];
collapsedFolderIds: string[];
folderHeaderIsExpanded: boolean;
tagHeaderIsExpanded: boolean;
}
const onAddFolderButtonClick = () => {
void CommandService.instance().execute('newFolder');
};
const onHeaderClick = (headerId: HeaderId) => {
const settingKey = headerId === HeaderId.TagHeader ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded';
const current = Setting.value(settingKey);
Setting.setValue(settingKey, !current);
};
const useSidebarListData = (props: Props): ListItem[] => {
const tagItems = useMemo(() => {
return renderTags<ListItem>(props.tags, (tag): TagListItem => {
return {
kind: ListItemType.Tag,
tag,
key: tag.id,
};
});
}, [props.tags]);
const folderItems = useMemo(() => {
const renderProps = {
folders: props.folders,
collapsedFolderIds: props.collapsedFolderIds,
};
return renderFolders<ListItem>(renderProps, (folder, hasChildren, depth): FolderListItem => {
return {
kind: ListItemType.Folder,
folder,
hasChildren,
depth,
key: folder.id,
};
});
}, [props.folders, props.collapsedFolderIds]);
return useMemo(() => {
const foldersHeader: HeaderListItem = {
kind: ListItemType.Header,
label: _('Notebooks'),
iconName: 'icon-notebooks',
id: HeaderId.FolderHeader,
key: HeaderId.FolderHeader,
onClick: onHeaderClick,
onPlusButtonClick: onAddFolderButtonClick,
extraProps: {
['data-folder-id']: '',
},
supportsFolderDrop: true,
};
const foldersSectionContent: ListItem[] = props.folderHeaderIsExpanded ? [
{ kind: ListItemType.AllNotes, key: 'all-notes' },
...folderItems.items,
{ kind: ListItemType.Spacer, key: 'after-folders-spacer' },
] : [];
const tagsHeader: HeaderListItem = {
kind: ListItemType.Header,
label: _('Tags'),
iconName: 'icon-tags',
id: HeaderId.TagHeader,
key: HeaderId.TagHeader,
onClick: onHeaderClick,
onPlusButtonClick: null,
extraProps: { },
supportsFolderDrop: false,
};
const tagsSectionContent: ListItem[] = props.tagHeaderIsExpanded ? tagItems.items : [];
const items: ListItem[] = [
foldersHeader,
...foldersSectionContent,
tagsHeader,
...tagsSectionContent,
];
return items;
}, [tagItems, folderItems, props.folderHeaderIsExpanded, props.tagHeaderIsExpanded]);
};
export default useSidebarListData;

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import { StyledAllNotesIcon, StyledListItem, StyledListItemAnchor } from '../styles';
import { useCallback } from 'react';
import { Dispatch } from 'redux';
import bridge from '../../../services/bridge';
import Setting from '@joplin/lib/models/Setting';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
import { _ } from '@joplin/lib/locale';
import { connect } from 'react-redux';
import EmptyExpandLink from './EmptyExpandLink';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
interface Props {
dispatch: Dispatch;
selected: boolean;
anchorRef: React.Ref<HTMLAnchorElement>;
}
const menuUtils = new MenuUtils(CommandService.instance());
const AllNotesItem: React.FC<Props> = props => {
const onAllNotesClick_ = useCallback(() => {
props.dispatch({
type: 'SMART_FILTER_SELECT',
id: ALL_NOTES_FILTER_ID,
});
}, [props.dispatch]);
const toggleAllNotesContextMenu = useCallback(() => {
const menu = new Menu();
if (Setting.value('notes.perFolderSortOrderEnabled')) {
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', ALL_NOTES_FILTER_ID),
type: 'checkbox',
checked: PerFolderSortOrderService.isSet(ALL_NOTES_FILTER_ID),
}));
}
menu.popup({ window: bridge().window() });
}, []);
return (
<StyledListItem key="allNotesHeader" selected={props.selected} className={'list-item-container list-item-depth-0 all-notes'} isSpecialItem={true}>
<EmptyExpandLink/>
<StyledAllNotesIcon className="icon-notes"/>
<StyledListItemAnchor
ref={props.anchorRef}
className="list-item"
isSpecialItem={true}
href="#"
selected={props.selected}
onClick={onAllNotesClick_}
onContextMenu={toggleAllNotesContextMenu}
>
{_('All notes')}
</StyledListItemAnchor>
</StyledListItem>
);
};
export default connect()(AllNotesItem);

View File

@@ -0,0 +1,11 @@
import * as React from 'react';
import ExpandIcon from './ExpandIcon';
interface Props {
}
const EmptyExpandLink: React.FC<Props> = _props => {
return <a className='sidebar-expand-link'><ExpandIcon isVisible={false} isExpanded={false}/></a>;
};
export default EmptyExpandLink;

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
type ExpandIconProps = {
isExpanded: boolean;
isVisible: true;
targetTitle: string;
}|{
isExpanded: boolean;
isVisible: false;
targetTitle?: string;
};
const ExpandIcon: React.FC<ExpandIconProps> = props => {
const classNames = ['sidebar-expand-icon'];
if (props.isVisible) classNames.push('-visible');
classNames.push(props.isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right');
// Referencing the name of the item we expand/collapse is both good for accessibility
// and makes writing tests easier.
const getLabel = () => {
if (!props.isVisible) {
return undefined;
}
if (props.isExpanded) {
return _('Collapse %s', props.targetTitle);
}
return _('Expand %s', props.targetTitle);
};
return <i className={classNames.join(' ')} aria-label={getLabel()}></i>;
};
export default ExpandIcon;

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import { MouseEventHandler } from 'react';
import ExpandIcon from './ExpandIcon';
import EmptyExpandLink from './EmptyExpandLink';
interface ExpandLinkProps {
folderId: string;
folderTitle: string;
hasChildren: boolean;
isExpanded: boolean;
onClick: MouseEventHandler<HTMLElement>;
}
const ExpandLink: React.FC<ExpandLinkProps> = props => {
return props.hasChildren ? (
<a className='sidebar-expand-link' href="#" data-folder-id={props.folderId} onClick={props.onClick}>
<ExpandIcon isVisible={true} isExpanded={props.isExpanded} targetTitle={props.folderTitle}/>
</a>
) : (
<EmptyExpandLink/>
);
};
export default ExpandLink;

View File

@@ -0,0 +1,89 @@
import * as React from 'react';
import { FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import ExpandLink from './ExpandLink';
import { StyledListItem, StyledListItemAnchor, StyledNoteCount, StyledShareIcon, StyledSpanFix } from '../styles';
import { ItemClickListener, ItemContextMenuListener, ItemDragListener } from '../types';
import FolderIconBox from '../../FolderIconBox';
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash';
import Folder from '@joplin/lib/models/Folder';
import { ModelType } from '@joplin/lib/BaseModel';
const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) {
const defaultFolderIcon: FolderIcon = {
dataUrl: '',
emoji: '',
name: 'far fa-folder',
type: FolderIconType.FontAwesome,
};
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox opacity={0.7} folderIcon={defaultFolderIcon}/></div>;
}
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox folderIcon={folderIcon}/></div>;
};
interface FolderItemProps {
hasChildren: boolean;
showFolderIcon: boolean;
isExpanded: boolean;
parentId: string;
depth: number;
folderId: string;
folderTitle: string;
folderIcon: FolderIcon;
noteCount: number;
onFolderDragStart_: ItemDragListener;
onFolderDragOver_: ItemDragListener;
onFolderDrop_: ItemDragListener;
itemContextMenu: ItemContextMenuListener;
folderItem_click: (folderId: string)=> void;
onFolderToggleClick_: ItemClickListener;
shareId: string;
selected: boolean;
anchorRef: React.Ref<HTMLElement>;
}
function FolderItem(props: FolderItemProps) {
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const noteCountComp = noteCount ? <StyledNoteCount className="note-count-label">{noteCount}</StyledNoteCount> : null;
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
const draggable = ![getTrashFolderId(), Folder.conflictFolderId()].includes(folderId);
const doRenderFolderIcon = () => {
if (folderId === getTrashFolderId()) {
return renderFolderIcon(getTrashFolderIcon(FolderIconType.FontAwesome));
}
if (!showFolderIcon) return null;
return renderFolderIcon(folderIcon);
};
return (
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={draggable} data-folder-id={folderId}>
<ExpandLink hasChildren={hasChildren} folderTitle={folderTitle} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
<StyledListItemAnchor
ref={props.anchorRef}
className="list-item"
isConflictFolder={folderId === Folder.conflictFolderId()}
href="#"
selected={selected}
shareId={shareId}
data-id={folderId}
data-type={ModelType.Folder}
onContextMenu={itemContextMenu}
data-folder-id={folderId}
onClick={() => {
folderItem_click(folderId);
}}
onDoubleClick={onFolderToggleClick_}
>
{doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix>
{shareIcon} {noteCountComp}
</StyledListItemAnchor>
</StyledListItem>
);
}
export default FolderItem;

View File

@@ -0,0 +1,72 @@
import * as React from 'react';
import { useCallback } from 'react';
import { ButtonLevel } from '../../Button/Button';
import { StyledAddButton, StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
import { HeaderId, HeaderListItem } from '../types';
import { _ } from '@joplin/lib/locale';
import bridge from '../../../services/bridge';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const menuUtils = new MenuUtils(CommandService.instance());
interface Props {
item: HeaderListItem;
onDrop: React.DragEventHandler|null;
anchorRef: React.Ref<HTMLElement>;
}
const HeaderItem: React.FC<Props> = props => {
const item = props.item;
const onItemClick = item.onClick;
const itemId = item.id;
const onClick: React.MouseEventHandler<HTMLElement> = useCallback(event => {
if (onItemClick) {
onItemClick(itemId, event);
}
}, [onItemClick, itemId]);
const onContextMenu = useCallback(async () => {
if (itemId === HeaderId.FolderHeader) {
const menu = new Menu();
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder')),
);
menu.popup({ window: bridge().window() });
}
}, [itemId]);
const addButton = <StyledAddButton
iconLabel={_('New')}
onClick={item.onPlusButtonClick}
iconName='fas fa-plus'
level={ButtonLevel.SidebarSecondary}
/>;
return (
<div
className='sidebar-header-container'
{...item.extraProps}
onDrop={props.onDrop}
>
<StyledHeader
onContextMenu={onContextMenu}
onClick={onClick}
tabIndex={0}
ref={props.anchorRef}
>
<StyledHeaderIcon className={item.iconName}/>
<StyledHeaderLabel>{item.label}</StyledHeaderLabel>
</StyledHeader>
{ item.onPlusButtonClick && addButton }
</div>
);
};
export default HeaderItem;

View File

@@ -0,0 +1,14 @@
import * as React from 'react';
import { StyledNoteCount } from '../styles';
interface Props {
count: number;
}
const NoteCount: React.FC<Props> = props => {
const count = props.count;
return count ? <StyledNoteCount className="note-count-label">{count}</StyledNoteCount> : null;
};
export default NoteCount;

View File

@@ -0,0 +1,59 @@
import Setting from '@joplin/lib/models/Setting';
import * as React from 'react';
import { useCallback } from 'react';
import { StyledListItem, StyledListItemAnchor, StyledSpanFix } from '../styles';
import { TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import BaseModel from '@joplin/lib/BaseModel';
import NoteCount from './NoteCount';
import Tag from '@joplin/lib/models/Tag';
import EmptyExpandLink from './EmptyExpandLink';
export type TagLinkClickEvent = { tag: TagsWithNoteCountEntity|undefined };
interface Props {
selected: boolean;
anchorRef: React.Ref<HTMLElement>;
tag: TagsWithNoteCountEntity;
onTagDrop: React.DragEventHandler<HTMLElement>;
onContextMenu: React.MouseEventHandler<HTMLElement>;
onClick: (event: TagLinkClickEvent)=> void;
}
const TagItem = (props: Props) => {
const { tag, selected } = props;
let noteCount = null;
if (Setting.value('showNoteCounts')) {
const count = Setting.value('showCompletedTodos') ? tag.note_count : tag.note_count - tag.todo_completed_count;
noteCount = <NoteCount count={count}/>;
}
const onClickHandler = useCallback(() => {
props.onClick({ tag });
}, [props.onClick, tag]);
return (
<StyledListItem selected={selected}
className={`list-item-container ${selected ? 'selected' : ''}`}
onDrop={props.onTagDrop}
data-tag-id={tag.id}
>
<EmptyExpandLink/>
<StyledListItemAnchor
ref={props.anchorRef}
className="list-item"
href="#"
selected={selected}
data-id={tag.id}
data-type={BaseModel.TYPE_TAG}
onContextMenu={props.onContextMenu}
onClick={onClickHandler}
>
<StyledSpanFix className="tag-label">{Tag.displayTitle(tag)}</StyledSpanFix>
{noteCount}
</StyledListItemAnchor>
</StyledListItem>
);
};
export default TagItem;

View File

@@ -0,0 +1,5 @@
@use 'styles/folder-and-tag-list.scss';
@use 'styles/sidebar-expand-icon.scss';
@use 'styles/sidebar-expand-link.scss';
@use 'styles/sidebar-header-container.scss';
@use 'styles/sidebar-spacer-item.scss';

View File

@@ -0,0 +1,14 @@
.folder-and-tag-list {
position: relative;
overflow: hidden;
flex-grow: 1;
height: 100%;
width: 100%;
> .items {
position: absolute;
left: 0;
right: 0;
}
}

View File

@@ -90,20 +90,6 @@ export const StyledShareIcon = styled.i`
margin-left: 8px;
`;
export const StyledExpandLink = styled.a`
color: ${(props: StyleProps) => props.theme.color2};
cursor: default;
opacity: 0.8;
text-decoration: none;
padding-right: 8px;
display: flex;
align-items: center;
width: 16px;
max-width: 16px;
min-width: 16px;
height: 100%;
`;
export const StyledNoteCount = styled.div`
color: ${(props: StyleProps) => props.theme.colorFaded2};
padding-left: 8px;

View File

@@ -0,0 +1,13 @@
.sidebar-expand-icon {
width: 16px;
max-width: 16px;
opacity: 0.5px;
font-size: calc(var(--joplin-toolbar-icon-size) * 0.8);
display: flex;
justify-content: center;
&:not(.-visible) {
visibility: hidden;
}
}

View File

@@ -0,0 +1,14 @@
.sidebar-expand-link {
color: var(--joplin-color2);
cursor: default;
opacity: 0.8;
text-decoration: none;
padding-right: 8px;
display: flex;
align-items: center;
width: 16px;
max-width: 16px;
min-width: 16px;
height: 100%;
}

View File

@@ -0,0 +1,6 @@
.sidebar-header-container {
display: flex;
flex-direction: row;
align-items: center;
}

View File

@@ -0,0 +1,5 @@
.sidebar-spacer-item {
display: block;
height: 30px;
}

View File

@@ -0,0 +1,64 @@
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import { DragEventHandler, MouseEventHandler, MouseEvent as ReactMouseEvent } from 'react';
export enum HeaderId {
TagHeader = 'tagHeader',
FolderHeader = 'folderHeader',
}
export enum ListItemType {
Header = 'header',
Tag = 'tag',
Folder = 'folder',
AllNotes = 'all-notes',
Spacer = 'spacer',
}
interface BaseListItem {
key: string;
}
export interface HeaderListItem extends BaseListItem {
kind: ListItemType.Header;
label: string;
iconName: string;
id: HeaderId;
onClick: ((headerId: HeaderId, event: ReactMouseEvent<HTMLElement>)=> void)|null;
onPlusButtonClick: MouseEventHandler<HTMLElement>|null;
extraProps: Record<string, string>;
supportsFolderDrop: boolean;
}
export interface AllNotesListItem extends BaseListItem {
kind: ListItemType.AllNotes;
}
export interface TagListItem extends BaseListItem {
kind: ListItemType.Tag;
tag: TagsWithNoteCountEntity;
}
export interface FolderListItem extends BaseListItem {
kind: ListItemType.Folder;
folder: FolderEntity;
hasChildren: boolean;
depth: number;
}
export interface SpacerListItem extends BaseListItem {
kind: ListItemType.Spacer;
}
export type ListItem = HeaderListItem|AllNotesListItem|TagListItem|FolderListItem|SpacerListItem;
export type SetSelectedIndexCallback = (newIndex: number)=> void;
export type ItemDragListener = DragEventHandler<HTMLElement>;
export type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
export type ItemClickListener = MouseEventHandler<HTMLElement>;
export interface SidebarCommandRuntimeProps {
focusSidebar: ()=> void;
}

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
// This uses a ResizeObserver -- be careful to prevent infinite loops (should be stopped
// early and print a warning). See https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
const useElementHeight = (container: HTMLElement|null) => {
const [height, setHeight] = useState(container?.clientHeight ?? 0);
useEffect(() => {
if (!container) return () => {};
const observer = new ResizeObserver(() => {
setHeight(container.clientHeight);
});
observer.observe(container);
return () => {
observer.disconnect();
};
}, [container]);
return height;
};
export default useElementHeight;

View File

@@ -1,21 +1,25 @@
import { Page, Locator, ElectronApplication } from '@playwright/test';
import NoteEditorScreen from './NoteEditorScreen';
import activateMainMenuItem from '../util/activateMainMenuItem';
import Sidebar from './Sidebar';
export default class MainScreen {
public readonly newNoteButton: Locator;
public readonly noteListContainer: Locator;
public readonly sidebar: Sidebar;
public readonly dialog: Locator;
public readonly noteEditor: NoteEditorScreen;
public constructor(private page: Page) {
this.newNoteButton = page.locator('.new-note-button');
this.noteListContainer = page.locator('.rli-noteList');
this.sidebar = new Sidebar(page, this);
this.dialog = page.locator('.dialog-root');
this.noteEditor = new NoteEditorScreen(page);
}
public async waitFor() {
await this.newNoteButton.waitFor();
await this.noteEditor.waitFor();
await this.noteListContainer.waitFor();
}

View File

@@ -0,0 +1,46 @@
import activateMainMenuItem from '../util/activateMainMenuItem';
import type MainScreen from './MainScreen';
import { ElectronApplication, Locator, Page } from '@playwright/test';
export default class Sidebar {
public readonly container: Locator;
public constructor(page: Page, private mainScreen: MainScreen) {
this.container = page.locator('.rli-sideBar');
}
public async createNewFolder(title: string) {
const newFolderButton = this.container.getByRole('button', { name: 'New' });
await newFolderButton.click();
const titleInput = this.mainScreen.dialog.getByLabel('Title');
await titleInput.fill(title);
const submitButton = this.mainScreen.dialog.getByRole('button', { name: 'OK' });
await submitButton.click();
return this.container.getByText(title);
}
private async sortBy(electronApp: ElectronApplication, option: string) {
const success = await activateMainMenuItem(electronApp, option, 'Sort notebooks by');
if (!success) {
throw new Error(`Failed to find menu item: ${option}`);
}
}
public async sortByDate(electronApp: ElectronApplication) {
return this.sortBy(electronApp, 'Updated date');
}
public async sortByTitle(electronApp: ElectronApplication) {
return this.sortBy(electronApp, 'Title');
}
public async forceUpdateSorting(electronApp: ElectronApplication) {
// By default, notebooks will not be in the correct position in the list for about 1 second.
// Change the notebook list sort order to force an immediate refresh.
await this.sortByDate(electronApp);
await this.sortByTitle(electronApp);
}
}

View File

@@ -0,0 +1,104 @@
import { test, expect } from './util/test';
import MainScreen from './models/MainScreen';
test.describe('sidebar', () => {
test('should be able to create new folders', async ({ mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar;
for (let i = 0; i < 3; i++) {
const title = `Test folder ${i}`;
await sidebar.createNewFolder(title);
await expect(sidebar.container.getByText(title)).toBeAttached();
}
// The first folder should still be visible
await expect(sidebar.container.getByText('Test folder 0')).toBeAttached();
});
test('should allow changing the focused folder with the arrow keys', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar;
const folderAHeader = await sidebar.createNewFolder('Folder A');
await expect(folderAHeader).toBeVisible();
const folderBHeader = await sidebar.createNewFolder('Folder B');
await expect(folderBHeader).toBeVisible();
await folderBHeader.click();
await sidebar.forceUpdateSorting(electronApp);
await folderBHeader.click();
await mainWindow.keyboard.press('ArrowUp');
await expect(mainWindow.locator(':focus')).toHaveText('Folder A');
await mainWindow.keyboard.press('ArrowDown');
await expect(mainWindow.locator(':focus')).toHaveText('Folder B');
await mainWindow.keyboard.press('ArrowUp');
await expect(mainWindow.locator(':focus')).toHaveText('Folder A');
await mainWindow.keyboard.press('ArrowUp');
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
await mainWindow.keyboard.press('ArrowUp');
await expect(mainWindow.locator(':focus')).toHaveText(/NOTEBOOKS/i);
await mainWindow.keyboard.press('ArrowDown');
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
});
test('should allow changing the parent of a folder by drag-and-drop', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar;
const parentFolderHeader = await sidebar.createNewFolder('Parent folder');
await expect(parentFolderHeader).toBeVisible();
const childFolderHeader = await sidebar.createNewFolder('Child folder');
await expect(childFolderHeader).toBeVisible();
await sidebar.forceUpdateSorting(electronApp);
await childFolderHeader.dragTo(parentFolderHeader);
// Verify that it's now a child folder -- expand and collapse the parent
const collapseButton = sidebar.container.getByRole('link', { name: 'Collapse Parent folder' });
await expect(collapseButton).toBeVisible();
await collapseButton.click();
// Should be collapsed
await expect(childFolderHeader).not.toBeAttached();
const expandButton = sidebar.container.getByRole('link', { name: 'Expand Parent folder' });
await expandButton.click();
// Should be possible to move back to the root
const rootFolderHeader = sidebar.container.getByText('Notebooks');
await childFolderHeader.dragTo(rootFolderHeader);
await expect(collapseButton).not.toBeVisible();
await expect(expandButton).not.toBeVisible();
});
test('all notes section should list all notes', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar;
const testFolderA = await sidebar.createNewFolder('Folder A');
await expect(testFolderA).toBeAttached();
await sidebar.forceUpdateSorting(electronApp);
await mainScreen.createNewNote('A note in Folder A');
await expect(mainWindow.getByText('A note in Folder A')).toBeAttached();
await mainScreen.createNewNote('Another note in Folder A');
const testFolderB = await sidebar.createNewFolder('Folder B');
await expect(testFolderB).toBeAttached();
await mainScreen.createNewNote('A note in Folder B');
const allNotesButton = sidebar.container.getByText('All notes');
await allNotesButton.click();
await expect(mainWindow.getByText('A note in Folder A')).toBeAttached();
await expect(mainWindow.getByText('Another note in Folder A')).toBeAttached();
await expect(mainWindow.getByText('A note in Folder B')).toBeAttached();
});
});

View File

@@ -6,17 +6,22 @@ import type { MenuItem } from 'electron';
// Roughly based on
// https://github.com/spaceagetv/electron-playwright-helpers/blob/main/src/menu_helpers.ts
// `menuItemPath` should be a list of menu labels (e.g. [["&JoplinMainMenu", "&File"], "Synchronise"]).
const activateMainMenuItem = (electronApp: ElectronApplication, menuItemLabel: string) => {
return electronApp.evaluate(async ({ Menu }, menuItemLabel) => {
const activateItemInSubmenu = (submenu: MenuItem[]) => {
// If given, `parentMenuLabel` should be the label of the menu containing the target item.
const activateMainMenuItem = (
electronApp: ElectronApplication,
targetItemLabel: string,
parentMenuLabel?: string,
) => {
return electronApp.evaluate(async ({ Menu }, [targetItemLabel, parentMenuLabel]) => {
const activateItemInSubmenu = (submenu: MenuItem[], parentLabel: string) => {
for (const item of submenu) {
if (item.label === menuItemLabel && item.visible) {
const matchesParent = !parentMenuLabel || parentLabel === parentMenuLabel;
if (item.label === targetItemLabel && matchesParent && item.visible) {
// Found!
item.click();
return true;
} else if (item.submenu) {
const foundItem = activateItemInSubmenu(item.submenu.items);
const foundItem = activateItemInSubmenu(item.submenu.items, item.label);
if (foundItem) {
return true;
@@ -29,8 +34,8 @@ const activateMainMenuItem = (electronApp: ElectronApplication, menuItemLabel: s
};
const appMenu = Menu.getApplicationMenu();
return activateItemInSubmenu(appMenu.items);
}, menuItemLabel);
return activateItemInSubmenu(appMenu.items, '');
}, [targetItemLabel, parentMenuLabel]);
};
export default activateMainMenuItem;

View File

@@ -26,7 +26,7 @@ const shim = require('@joplin/lib/shim').default;
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const bridge = require('@electron/remote').require('./bridge').default;
const EncryptionService = require('@joplin/lib/services/e2ee/EncryptionService').default;
const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local');
const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default;
const React = require('react');
const nodeSqlite = require('sqlite3');
const initLib = require('@joplin/lib/initLib').default;

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.0.3",
"version": "3.0.4",
"description": "Joplin for Desktop",
"main": "main.js",
"private": true,
@@ -126,7 +126,7 @@
"@playwright/test": "1.42.1",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.8",
"@types/node": "18.19.8",
"@types/node": "18.19.10",
"@types/react": "18.2.48",
"@types/react-redux": "7.1.33",
"@types/styled-components": "5.1.32",

View File

@@ -8,7 +8,9 @@ import BasePlatformImplementation, { Joplin } from '@joplin/lib/services/plugins
import { CreateFromPdfOptions, Implementation as ImagingImplementation } from '@joplin/lib/services/plugins/api/JoplinImaging';
import shim from '@joplin/lib/shim';
import { join } from 'path';
import uuid from '@joplin/lib/uuid';
import uuid, { uuidgen } from '@joplin/lib/uuid';
import { hasProtocol } from '@joplin/utils/url';
import { fileExtension } from '@joplin/utils/path';
const { clipboard, nativeImage } = require('electron');
const packageInfo = require('../../packageInfo');
@@ -97,24 +99,33 @@ export default class PlatformImplementation extends BasePlatformImplementation {
await shim.fsDriver().remove(tempDir);
}
};
return {
nativeImage: {
async createFromPath(path: string) {
if (path.toLowerCase().endsWith('.pdf')) {
const images = await createFromPdf(path, { minPage: 1, maxPage: 1 });
createFromPath: async (path: string) => {
let pathToProcess = path;
let ext = fileExtension(path).toLowerCase();
if (images.length === 0) {
// Match the behavior or Electron's nativeImage when reading an invalid image.
return nativeImage.createEmpty();
}
if (hasProtocol(path, ['http', 'https', 'file'])) {
ext = fileExtension((new URL(path)).pathname);
const tempFilePath = `${Setting.value('tempDir')}/${uuidgen()}${ext ? `.${ext}` : ''}`;
await shim.fetchBlob(path, { path: tempFilePath });
pathToProcess = tempFilePath;
}
return images[0];
} else {
return nativeImage.createFromPath(path);
if (ext === 'pdf') {
const images = await createFromPdf(pathToProcess, { minPage: 1, maxPage: 1 });
if (images.length === 0) {
// Match the behavior or Electron's nativeImage when reading an invalid image.
return nativeImage.createEmpty();
}
},
createFromPdf,
return images[0];
} else {
return nativeImage.createFromPath(pathToProcess);
}
},
createFromPdf,
getPdfInfo(path: string) {
return shim.pdfInfo(path);
},

View File

@@ -1,18 +1,49 @@
import PerFolderSortOrderService from './PerFolderSortOrderService';
import { setNotesSortOrder } from './notesSortOrderUtils';
import Setting from '@joplin/lib/models/Setting';
import { AppState, createAppDefaultState } from '../../app.reducer';
import eventManager from '@joplin/lib/eventManager';
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
const folderId1 = 'aa012345678901234567890123456789';
const folderId2 = 'bb012345678901234567890123456789';
let appState: AppState|null = null;
const updateAppState = (update: Partial<AppState>) => {
appState = { ...appState, ...update };
eventManager.appStateEmit(appState);
};
const switchToFolder = (id: string) => {
updateAppState({
notesParentType: 'Folder',
selectedFolderId: id,
});
};
const switchToAllNotes = () => {
updateAppState({
notesParentType: 'SmartFilter',
selectedSmartFilterId: ALL_NOTES_FILTER_ID,
});
};
describe('PerFolderSortOrderService', () => {
beforeAll(async () => {
shimInit();
Setting.autoSaveEnabled = false;
});
beforeEach(() => {
PerFolderSortOrderService.initialize();
Setting.setValue('notes.perFolderSortOrderEnabled', true);
updateAppState(createAppDefaultState({}, {}));
switchToFolder(folderId1);
});
afterEach(() => {
Setting.setValue('notes.perFolderSortOrders', {});
});
test('get(), isSet() and set()', async () => {
@@ -39,4 +70,71 @@ describe('PerFolderSortOrderService', () => {
// Folder without per-folder sort order has no per-folder sort order
expect(PerFolderSortOrderService.get(folderId2)).toBeUndefined();
});
test('should allow specifying a sort order specific to a folder', () => {
switchToFolder(folderId1);
expect(PerFolderSortOrderService.isSet(folderId1)).toBe(false);
expect(PerFolderSortOrderService.isSet(folderId2)).toBe(false);
setNotesSortOrder('user_created_time', false);
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
// Folder 2 should use the shared sort order.
switchToFolder(folderId2);
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
// If changing the per-folder sort order for folder 1, folder 2 should continue
// to use the shared sort order.
switchToFolder(folderId1);
PerFolderSortOrderService.set(folderId1, true);
setNotesSortOrder('title', true);
expect(Setting.value('notes.sortOrder.field')).toBe('title');
expect(Setting.value('notes.sortOrder.reverse')).toBe(true);
switchToFolder(folderId2);
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
});
test('should allow setting a sort order specific to All Notes', () => {
switchToFolder(folderId1);
expect(PerFolderSortOrderService.isSet(ALL_NOTES_FILTER_ID)).toBe(false);
expect(PerFolderSortOrderService.isSet(folderId1)).toBe(false);
// Set default shared sort order
setNotesSortOrder('user_created_time', false);
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
// Switching to all notes should not change the default sort order.
switchToAllNotes();
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
// It should be possible to enable per-folder sorting for all notes.
PerFolderSortOrderService.set(ALL_NOTES_FILTER_ID, true);
expect(PerFolderSortOrderService.isSet(ALL_NOTES_FILTER_ID)).toBe(true);
setNotesSortOrder('user_updated_time', true);
// Per-folder sorting should be respected for all notes
switchToFolder(folderId1);
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
// Shared sort order should be overriden by per-folder sorting
setNotesSortOrder('title', false);
switchToAllNotes();
expect(Setting.value('notes.sortOrder.field')).toBe('user_updated_time');
expect(Setting.value('notes.sortOrder.reverse')).toBe(true);
});
});

View File

@@ -13,6 +13,7 @@ export interface SortOrder {
interface FolderState {
notesParentType: string;
selectedFolderId: string;
selectedSmartFilterId: string;
}
interface SortOrderPool {
@@ -21,8 +22,10 @@ interface SortOrderPool {
export default class PerFolderSortOrderService {
// To support a custom sort order in "all notebooks", previousFolderId
// can also be a smart filter ID.
private static previousFolderId: string = null;
private static folderState: FolderState = { notesParentType: '', selectedFolderId: '' };
private static folderState: FolderState = { notesParentType: '', selectedFolderId: '', selectedSmartFilterId: '' };
// Since perFolderSortOrders and sharedSortOrder is persisted using Setting,
// their structures are not nested.
private static perFolderSortOrders: SortOrderPool = null;
@@ -40,6 +43,7 @@ export default class PerFolderSortOrderService {
this.loadSharedSortOrder();
eventManager.appStateOn('notesParentType', this.onFolderSelectionMayChange.bind(this, 'notesParentType'));
eventManager.appStateOn('selectedFolderId', this.onFolderSelectionMayChange.bind(this, 'selectedFolderId'));
eventManager.appStateOn('selectedSmartFilterId', this.onFolderSelectionMayChange.bind(this, 'selectedSmartFilterId'));
this.previousFolderId = Setting.value('activeFolderId');
}
@@ -93,7 +97,9 @@ export default class PerFolderSortOrderService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private static onFolderSelectionMayChange(cause: string, event: any) {
if (cause !== 'notesParentType' && cause !== 'selectedFolderId') return;
if (cause !== 'notesParentType' && cause !== 'selectedFolderId' && cause !== 'selectedSmartFilterId') {
return;
}
this.folderState[cause] = event.value;
const selectedId = this.getSelectedFolderId();
if (this.previousFolderId === selectedId) return;
@@ -127,6 +133,8 @@ export default class PerFolderSortOrderService {
private static getSelectedFolderId(): string {
if (this.folderState.notesParentType === 'Folder') {
return this.folderState.selectedFolderId;
} else if (this.folderState.notesParentType === 'SmartFilter') {
return this.folderState.selectedSmartFilterId;
} else {
return '';
}

View File

@@ -9,4 +9,5 @@
@use 'gui/JoplinCloudLoginScreen.scss' as joplin-cloud-login-screen;
@use 'gui/NoteListHeader/style.scss' as note-list-header;
@use 'gui/TrashNotification/style.scss' as trash-notification;
@use 'gui/Sidebar/style.scss' as sidebar-styles;
@use 'main.scss' as main;

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/plugin-abc
PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/imaging
if [[ $NEED_COMPILING == 1 ]]; then
mkdir -p "$TEMP_PATH"
@@ -18,7 +18,7 @@ if [[ $NEED_COMPILING == 1 ]]; then
rsync -a --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
NODE_OPTIONS=--openssl-legacy-provider npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
else
yarn start --dev-plugins "$PLUGIN_PATH"
fi

View File

@@ -110,8 +110,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097740
versionName "3.0.1"
versionCode 2097742
versionName "3.0.3"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -0,0 +1,83 @@
import * as React from 'react';
import { useMemo } from 'react';
import { StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native';
import { IconButton, Surface } from 'react-native-paper';
import { themeStyle } from './global-style';
import Modal from './Modal';
import { _ } from '@joplin/lib/locale';
interface Props {
themeId: number;
visible: boolean;
onDismiss: ()=> void;
containerStyle?: ViewStyle;
children: React.ReactNode;
}
const useStyles = (themeId: number, containerStyle: ViewStyle) => {
const windowSize = useWindowDimensions();
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
webView: {
backgroundColor: 'transparent',
display: 'flex',
},
webViewContainer: {
flexGrow: 1,
flexShrink: 1,
},
closeButtonContainer: {
flexDirection: 'row',
justifyContent: 'flex-end',
},
dialog: {
backgroundColor: theme.backgroundColor,
borderRadius: 12,
padding: 10,
height: windowSize.height * 0.9,
width: windowSize.width * 0.97,
// Center
marginLeft: 'auto',
marginRight: 'auto',
...containerStyle,
},
});
}, [themeId, windowSize.width, windowSize.height, containerStyle]);
};
const DismissibleDialog: React.FC<Props> = props => {
const styles = useStyles(props.themeId, props.containerStyle);
const closeButton = (
<View style={styles.closeButtonContainer}>
<IconButton
icon='close'
accessibilityLabel={_('Close')}
onPress={props.onDismiss}
/>
</View>
);
return (
<Modal
visible={props.visible}
onDismiss={props.onDismiss}
onRequestClose={props.onDismiss}
animationType='fade'
transparent={true}
>
<Surface style={styles.dialog} elevation={1}>
{closeButton}
{props.children}
</Surface>
</Modal>
);
};
export default DismissibleDialog;

View File

@@ -29,7 +29,7 @@ export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRe
return;
}
const name = resource.title ? resource.title : resource.file_name;
const name = resource.title ? resource.title : resource.filename;
const mime: string|undefined = resource.mime;
const actions = [];

View File

@@ -133,7 +133,7 @@ export const createJsDrawEditor = (
const request = new XMLHttpRequest();
const onError = () => {
reject(`Failed to load initial SVG data: ${request.status}, ${request.statusText}, ${request.responseText}`);
reject(new Error(`Failed to load initial SVG data: ${request.status}, ${request.statusText}, ${request.responseText}`));
};
request.addEventListener('load', _ => {

View File

@@ -6,6 +6,7 @@ import { connect } from 'react-redux';
import { FlatList, Text, StyleSheet, Button, View } from 'react-native';
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
import { AppState } from '../utils/types';
import getEmptyFolderMessage from '@joplin/lib/components/shared/NoteList/getEmptyFolderMessage';
import Folder from '@joplin/lib/models/Folder';
const { _ } = require('@joplin/lib/locale');
@@ -20,7 +21,7 @@ interface NoteListProps {
items: NoteEntity[];
folders: FolderEntity[];
noteSelectionEnabled?: boolean;
selectedFolderId?: string;
selectedFolderId: string|null;
}
class NoteListComponent extends Component<NoteListProps> {
@@ -102,8 +103,9 @@ class NoteListComponent extends Component<NoteListProps> {
</View>
);
} else {
const noItemMessage = _('There are currently no notes. Create one by clicking on the (+) button.');
return <Text style={this.styles().noItemMessage}>{noItemMessage}</Text>;
return <Text style={this.styles().noItemMessage}>
{getEmptyFolderMessage(this.props.folders, this.props.selectedFolderId)}
</Text>;
}
}
}
@@ -117,6 +119,7 @@ const NoteList = connect((state: AppState) => {
notesSource: state.notesSource,
themeId: state.settings.theme,
noteSelectionEnabled: state.noteSelectionEnabled,
selectedFolderId: state.selectedFolderId,
};
})(NoteListComponent);

View File

@@ -30,7 +30,7 @@ export interface ValueMap {
export default function getResponsiveValue(valueMap: ValueMap): number {
if (Object.keys(valueMap).length === 0) {
throw 'valueMap cannot be an empty object!';
throw new Error('valueMap cannot be an empty object!');
}
const width = Dimensions.get('window').width;

View File

@@ -8,7 +8,6 @@ import setIgnoreTlsErrors from '../../../utils/TlsUtils';
import { reg } from '@joplin/lib/registry';
import { State } from '@joplin/lib/reducer';
const { BackButtonService } = require('../../../services/back-button.js');
const VersionInfo = require('react-native-version-info').default;
import { connect } from 'react-redux';
import ScreenHeader from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
@@ -33,6 +32,8 @@ import PluginStates, { getSearchText as getPluginStatesSearchText } from './plug
import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton';
import NoteImportButton, { importButtonDefaultTitle, importButtonDescription } from './NoteExportSection/NoteImportButton';
import SectionDescription from './SectionDescription';
import getPackageInfo from '../../../utils/getPackageInfo';
import versionInfo from '@joplin/lib/versionInfo';
interface ConfigScreenState {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -345,6 +346,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
description={options?.description}
statusComponent={options?.statusComp}
styles={this.styles()}
disabled={options?.disabled}
/>
);
}
@@ -513,18 +515,27 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
if (section.name === 'joplinCloud') {
const label = _('Email to note');
const description = _('Any email sent to this address will be converted into a note and added to your collection. The note will be saved into the Inbox notebook');
const isEmailToNoteAvailableInAccount = this.props.settings['sync.10.accountType'] !== 1;
const inboxEmailValue = isEmailToNoteAvailableInAccount ? this.props.settings['sync.10.inboxEmail'] : '-';
addSettingComponent(
<View key="joplinCloud">
<View style={this.styles().styleSheet.settingContainerNoBottomBorder}>
<Text style={this.styles().styleSheet.settingText}>{label}</Text>
<Text style={this.styles().styleSheet.settingTextEmphasis}>{this.props.settings['sync.10.inboxEmail']}</Text>
<Text style={this.styles().styleSheet.settingTextEmphasis}>{inboxEmailValue}</Text>
</View>
{
!isEmailToNoteAvailableInAccount && (
<View style={this.styles().styleSheet.settingContainerNoBottomBorder}>
<Text style={this.styles().styleSheet.descriptionAlert}>{_('Your account doesn\'t have access to this feature')}</Text>
</View>
)
}
{
this.renderButton(
'sync.10.inboxEmail',
_('Copy to clipboard'),
() => Clipboard.setString(this.props.settings['sync.10.inboxEmail']),
{ description },
() => isEmailToNoteAvailableInAccount && Clipboard.setString(this.props.settings['sync.10.inboxEmail']),
{ description, disabled: !isEmailToNoteAvailableInAccount },
)
}
</View>,
@@ -592,13 +603,20 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
addSettingLink('website_link', _('Joplin website'), 'https://joplinapp.org/');
addSettingLink('privacy_link', _('Privacy Policy'), 'https://joplinapp.org/privacy/');
addSettingText('version_info_app', `Joplin ${VersionInfo.appVersion}`);
addSettingText('version_info_db', _('Database v%s', reg.db().version()));
addSettingText('version_info_sync', _('Sync Version: %s', Setting.value('syncVersion')));
addSettingText('version_info_client_id', _('Client ID: %s', Setting.value('clientId')));
addSettingText('version_info_fts', _('FTS enabled: %d', this.props.settings['db.ftsEnabled']));
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
addSettingText('version_info_hermes', _('Hermes enabled: %d', (global as any).HermesInternal ? 1 : 0));
const packageInfo = getPackageInfo();
const appInfo = versionInfo(packageInfo, PluginService.instance().enabledPlugins(settings['plugins.states']));
const versionInfoText = [
appInfo.body,
'',
_('FTS enabled: %d', this.props.settings['db.ftsEnabled']),
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
_('Hermes enabled: %d', (global as any).HermesInternal ? 1 : 0),
].join('\n');
addSettingText('version_info', versionInfoText);
addSettingButton('copy_app_info', _('Copy version info'), () => {
Clipboard.setString(versionInfoText);
});
const featureFlagKeys = Setting.featureFlagKeys(AppType.Mobile);
if (featureFlagKeys.length) {

View File

@@ -15,6 +15,7 @@ export interface ConfigScreenStyleSheet {
settingTextEmphasis: TextStyle;
linkText: TextStyle;
descriptionText: TextStyle;
descriptionAlert: TextStyle;
warningText: TextStyle;
sliderUnits: TextStyle;
@@ -119,6 +120,11 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
fontSize: theme.fontSizeSmaller,
flex: 1,
},
descriptionAlert: {
color: theme.color,
fontSize: theme.fontSizeSmaller,
flex: 1,
},
linkText: {
...settingTextStyle,
borderBottomWidth: 1,

View File

@@ -0,0 +1,115 @@
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import { useCallback, useMemo, useState } from 'react';
import { Button, IconButton, List, Portal, Text } from 'react-native-paper';
import getPluginIssueReportUrl from '@joplin/lib/services/plugins/utils/getPluginIssueReportUrl';
import { Linking, ScrollView, StyleSheet, View } from 'react-native';
import DismissibleDialog from '../../../../DismissibleDialog';
import openWebsiteForPlugin from '../utils/openWebsiteForPlugin';
interface Props {
themeId: number;
size: number;
item: PluginItem;
onModalDismiss?: ()=> void;
}
const styles = StyleSheet.create({
aboutPluginContainer: {
paddingLeft: 10,
paddingRight: 10,
paddingBottom: 10,
},
descriptionText: {
marginTop: 5,
marginBottom: 5,
},
fraudulentPluginButton: {
opacity: 0.6,
},
});
const PluginInfoModal: React.FC<Props> = props => {
const aboutPlugin = (
<View style={styles.aboutPluginContainer}>
<Text variant='titleLarge'>{props.item.manifest.name}</Text>
<Text variant='bodyLarge'>{props.item.manifest.author ? _('by %s', props.item.manifest.author) : ''}</Text>
<Text style={styles.descriptionText}>{props.item.manifest.description ?? _('No description')}</Text>
</View>
);
const onAboutPress = useCallback(() => {
void openWebsiteForPlugin({ item: props.item });
}, [props.item]);
const reportIssueUrl = useMemo(() => {
return getPluginIssueReportUrl(props.item.manifest);
}, [props.item]);
const onReportIssuePress = useCallback(() => {
void Linking.openURL(reportIssueUrl);
}, [reportIssueUrl]);
const reportIssueButton = (
<List.Item
left={props => <List.Icon {...props} icon='bug'/>}
title={_('Report an issue')}
onPress={onReportIssuePress}
/>
);
const onReportFraudulentPress = useCallback(() => {
void Linking.openURL('https://github.com/laurent22/joplin/security/advisories/new');
}, []);
return (
<Portal>
<DismissibleDialog
themeId={props.themeId}
visible={true}
onDismiss={props.onModalDismiss}
>
<ScrollView>
{aboutPlugin}
<List.Item
left={props => <List.Icon {...props} icon='web'/>}
title={_('About')}
onPress={onAboutPress}
/>
{ reportIssueUrl ? reportIssueButton : null }
</ScrollView>
<Button
icon='shield-bug'
style={styles.fraudulentPluginButton}
onPress={onReportFraudulentPress}
>{_('Report fraudulent plugin')}</Button>
</DismissibleDialog>
</Portal>
);
};
const PluginInfoButton: React.FC<Props> = props => {
const [showInfoModal, setShowInfoModal] = useState(false);
const onInfoButtonPress = useCallback(() => {
setShowInfoModal(true);
}, []);
const onModalDismiss = useCallback(() => {
setShowInfoModal(false);
props.onModalDismiss?.();
}, [props.onModalDismiss]);
return (
<>
{showInfoModal ? <PluginInfoModal {...props} onModalDismiss={onModalDismiss} /> : null}
<IconButton
size={props.size}
icon='information'
onPress={onInfoButtonPress}
/>
</>
);
};
export default PluginInfoButton;

View File

@@ -6,6 +6,7 @@ import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
import shim from '@joplin/lib/shim';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import ActionButton, { PluginCallback } from './ActionButton';
import PluginInfoButton from './PluginInfoButton';
export enum InstallState {
NotInstalled,
@@ -21,6 +22,7 @@ export enum UpdateState {
}
interface Props {
themeId: number;
item: PluginItem;
isCompatible: boolean;
@@ -28,11 +30,11 @@ interface Props {
installState?: InstallState;
updateState?: UpdateState;
onAboutPress?: PluginCallback;
onInstall?: PluginCallback;
onUpdate?: PluginCallback;
onDelete?: PluginCallback;
onToggle?: PluginCallback;
onAboutPress?: PluginCallback;
onShowPluginLog?: PluginCallback;
}
@@ -113,7 +115,7 @@ const PluginBox: React.FC<Props> = props => {
);
const disableButton = <ActionButton item={item} onPress={props.onToggle} title={_('Disable')}/>;
const enableButton = <ActionButton item={item} onPress={props.onToggle} title={_('Enable')}/>;
const aboutButton = <ActionButton icon='web' item={item} onPress={props.onAboutPress} title={_('About')}/>;
const aboutButton = <ActionButton item={item} onPress={props.onAboutPress} icon='web' title={_('About')}/>;
const renderErrorsChip = () => {
if (!props.hasErrors) return null;
@@ -165,6 +167,13 @@ const PluginBox: React.FC<Props> = props => {
);
};
const renderRightEdgeButton = (buttonProps: { size: number }) => {
// If .onAboutPress is given (e.g. when searching), there's another way to get information
// about the plugin. In this case, we don't show the right-side information link.
if (props.onAboutPress) return null;
return <PluginInfoButton {...buttonProps} themeId={props.themeId} item={props.item}/>;
};
const updateStateIsIdle = props.updateState !== UpdateState.Idle;
const titleComponent = <>
@@ -177,6 +186,7 @@ const PluginBox: React.FC<Props> = props => {
titleStyle={styles.title}
subtitle={manifest.description}
left={PluginIcon}
right={renderRightEdgeButton}
/>
<Card.Content>
<View style={{ flexDirection: 'row' }}>

View File

@@ -24,13 +24,12 @@ const shouldShowBasedOnSettingSearchQuery = ()=>true;
const PluginStatesWrapper = (props: WrapperProps) => {
const styles = configScreenStyles(Setting.THEME_LIGHT);
const [pluginStates, setPluginStates] = useState(() => {
return PluginService.instance().serializePluginSettings(props.initialPluginSettings ?? {});
const [pluginSettings, setPluginSettings] = useState(() => {
return props.initialPluginSettings ?? {};
});
const updatePluginStates = useCallback((newStates: PluginSettings) => {
const serialized = PluginService.instance().serializePluginSettings(newStates);
setPluginStates(serialized);
setPluginSettings(newStates);
}, []);
return (
@@ -38,7 +37,7 @@ const PluginStatesWrapper = (props: WrapperProps) => {
themeId={Setting.THEME_LIGHT}
styles={styles}
updatePluginStates={updatePluginStates}
pluginSettings={pluginStates}
pluginSettings={pluginSettings}
shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery}
/>
);

View File

@@ -1,21 +1,23 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ConfigScreenStyles } from '../configScreenStyles';
import { View } from 'react-native';
import { Banner, Button, Text } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
import PluginToggle from './PluginToggle';
import SearchPlugins from './SearchPlugins';
import { ItemEvent } from '@joplin/lib/components/shared/config/plugins/types';
import NavService from '@joplin/lib/services/NavService';
import useRepoApi from './utils/useRepoApi';
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
interface Props {
themeId: number;
styles: ConfigScreenStyles;
pluginSettings: string;
pluginSettings: SerializedPluginSettings;
settingsSearchQuery?: string;
updatePluginStates: (settingValue: PluginSettings)=> void;
@@ -35,6 +37,42 @@ export const getSearchText = () => {
return searchText;
};
const logger = Logger.create('PluginStates');
// Loaded plugins: All plugins with available manifests.
const useLoadedPluginIds = (pluginSettings: SerializedPluginSettings) => {
const allPluginIds = useMemo(() => {
return Object.keys(
PluginService.instance().unserializePluginSettings(pluginSettings),
);
}, [pluginSettings]);
const [pluginReloadCounter, setPluginReloadCounter] = useState(0);
const loadedPluginIds = useMemo(() => {
if (pluginReloadCounter > 0) {
logger.debug(`Not all plugins were loaded in the last render. Re-loading (try ${pluginReloadCounter})`);
}
const pluginService = PluginService.instance();
return allPluginIds.filter(id => !!pluginService.plugins[id]);
}, [allPluginIds, pluginReloadCounter]);
const hasLoadingPlugins = loadedPluginIds.length !== allPluginIds.length;
// Force a re-render if not all plugins have available metadata. This can happen
// if plugins are still loading.
const pluginReloadCounterRef = useRef(0);
pluginReloadCounterRef.current = pluginReloadCounter;
const timeoutRef = useRef(null);
if (hasLoadingPlugins && !timeoutRef.current) {
timeoutRef.current = shim.setTimeout(() => {
timeoutRef.current = null;
setPluginReloadCounter(pluginReloadCounterRef.current + 1);
}, 1000);
}
return loadedPluginIds;
};
const PluginStates: React.FC<Props> = props => {
const [repoApiError, setRepoApiError] = useState(null);
const [repoApiLoaded, setRepoApiLoaded] = useState(false);
@@ -91,14 +129,17 @@ const PluginStates: React.FC<Props> = props => {
const installedPluginCards = [];
const pluginService = PluginService.instance();
for (const key in pluginService.plugins) {
const plugin = pluginService.plugins[key];
const pluginIds = useLoadedPluginIds(props.pluginSettings);
for (const pluginId of pluginIds) {
const plugin = pluginService.plugins[pluginId];
if (!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(plugin.manifest.name)) {
installedPluginCards.push(
<PluginToggle
key={`plugin-${key}`}
pluginId={plugin.id}
key={`plugin-${pluginId}`}
themeId={props.themeId}
pluginId={pluginId}
styles={props.styles}
pluginSettings={props.pluginSettings}
updatablePluginIds={updatablePluginIds}

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { ConfigScreenStyles } from '../configScreenStyles';
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
import PluginService, { PluginSettings, defaultPluginSetting, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
import { useCallback, useMemo, useState } from 'react';
import PluginBox, { UpdateState } from './PluginBox';
import useOnDeleteHandler from '@joplin/lib/components/shared/config/plugins/useOnDeleteHandler';
@@ -11,8 +11,9 @@ import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
interface Props {
pluginId: string;
themeId: number;
styles: ConfigScreenStyles;
pluginSettings: string;
pluginSettings: SerializedPluginSettings;
updatablePluginIds: Record<string, boolean>;
repoApi: RepositoryApi;
@@ -91,6 +92,7 @@ const PluginToggle: React.FC<Props> = props => {
return (
<PluginBox
themeId={props.themeId}
item={pluginItem}
isCompatible={isCompatible}
hasErrors={plugin.hasErrors}

View File

@@ -1,6 +1,6 @@
import { _ } from '@joplin/lib/locale';
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
import PluginService, { PluginSettings, SerializedPluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
import * as React from 'react';
import { useCallback, useState } from 'react';
import { Button } from 'react-native-paper';
@@ -14,7 +14,7 @@ import Setting from '@joplin/lib/models/Setting';
interface Props {
updatePluginStates: (settingValue: PluginSettings)=> void;
pluginSettings: string;
pluginSettings: SerializedPluginSettings;
}
const logger = Logger.create('PluginUploadButton');

View File

@@ -7,8 +7,7 @@ import '@testing-library/react-native/extend-expect';
import SearchPlugins from './SearchPlugins';
import Setting from '@joplin/lib/models/Setting';
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import { useMemo } from 'react';
import { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import pluginServiceSetup from './testUtils/pluginServiceSetup';
import newRepoApi from './testUtils/newRepoApi';
@@ -22,14 +21,10 @@ interface WrapperProps {
const noOpFunction = ()=>{};
const SearchWrapper = (props: WrapperProps) => {
const serializedPluginSettings = useMemo(() => {
return PluginService.instance().serializePluginSettings(props.pluginSettings ?? {});
}, [props.pluginSettings]);
return (
<SearchPlugins
themeId={Setting.THEME_LIGHT}
pluginSettings={serializedPluginSettings}
pluginSettings={props.pluginSettings ?? {}}
repoApiInitialized={props.repoApiInitialized ?? true}
repoApi={props.repoApi}
onUpdatePluginStates={props.onUpdatePluginStates ?? noOpFunction}

View File

@@ -7,15 +7,15 @@ import { useCallback, useMemo, useState } from 'react';
import { FlatList, View } from 'react-native';
import { Searchbar } from 'react-native-paper';
import PluginBox, { InstallState } from './PluginBox';
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
import useInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler';
import { OnPluginSettingChangeEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
import onOpenWebsiteForPluginPress from './utils/openWebsiteForPlugin';
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
import openWebsiteForPlugin from './utils/openWebsiteForPlugin';
interface Props {
themeId: number;
pluginSettings: string;
pluginSettings: SerializedPluginSettings;
repoApiInitialized: boolean;
onUpdatePluginStates: (states: PluginSettings)=> void;
repoApi: RepositoryApi;
@@ -90,15 +90,16 @@ const PluginSearch: React.FC<Props> = props => {
return (
<PluginBox
themeId={props.themeId}
key={manifest.id}
item={item.item}
installState={item.installState}
isCompatible={PluginService.instance().isCompatible(manifest)}
onInstall={installPlugin}
onAboutPress={onOpenWebsiteForPluginPress}
onAboutPress={openWebsiteForPlugin}
/>
);
}, [installPlugin]);
}, [installPlugin, props.themeId]);
return (
<View style={{ flexDirection: 'column' }}>

View File

@@ -8,7 +8,7 @@ import Synchronizer from '@joplin/lib/Synchronizer';
import NavService from '@joplin/lib/services/NavService';
import { _ } from '@joplin/lib/locale';
import { ThemeStyle, themeStyle } from './global-style';
import { renderFolders } from '@joplin/lib/components/shared/side-menu-shared';
import { isFolderSelected, renderFolders } from '@joplin/lib/components/shared/side-menu-shared';
import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import { AppState } from '../utils/types';
import Setting from '@joplin/lib/models/Setting';
@@ -93,6 +93,8 @@ const SideMenuContentComponent = (props: Props) => {
fontSize: 22,
color: theme.color,
width: 26,
textAlign: 'center',
textAlignVertical: 'center',
},
};
@@ -391,7 +393,7 @@ const SideMenuContentComponent = (props: Props) => {
}
};
const renderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) => {
const renderFolderItem = (folder: FolderEntity, hasChildren: boolean, depth: number) => {
const theme = themeStyle(props.themeId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -403,6 +405,7 @@ const SideMenuContentComponent = (props: Props) => {
paddingRight: theme.marginRight,
paddingLeft: 10,
};
const selected = isFolderSelected(folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType });
if (selected) folderButtonStyle.backgroundColor = theme.selectedColor;
folderButtonStyle.paddingLeft = depth * 10 + theme.marginLeft;

View File

@@ -523,13 +523,13 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 113;
CURRENT_PROJECT_VERSION = 114;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 13.0.0;
MARKETING_VERSION = 13.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -552,12 +552,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 113;
CURRENT_PROJECT_VERSION = 114;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 13.0.0;
MARKETING_VERSION = 13.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -704,14 +704,14 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 113;
CURRENT_PROJECT_VERSION = 114;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 13.0.0;
MARKETING_VERSION = 13.0.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
@@ -735,14 +735,14 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 113;
CURRENT_PROJECT_VERSION = 114;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 13.0.0;
MARKETING_VERSION = 13.0.1;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -333,7 +333,7 @@ PODS:
- React-jsinspector (0.71.10)
- React-logger (0.71.10):
- glog
- react-native-alarm-notification (2.14.0):
- react-native-alarm-notification (3.0.0):
- React
- react-native-camera (4.2.1):
- React-Core
@@ -360,7 +360,7 @@ PODS:
- React-Core
- react-native-rsa-native (2.0.5):
- React
- react-native-saf-x (2.14.0):
- react-native-saf-x (3.0.0):
- React-Core
- react-native-safe-area-context (4.8.2):
- React-Core
@@ -780,7 +780,7 @@ SPEC CHECKSUMS:
React-jsiexecutor: 4bb480a183a354e4dbfb1012936b1a2bb9357de7
React-jsinspector: cdc854f8b13abd202afa54bc12578e5afb9cfae1
React-logger: ef2269b3afa6ba868da90496c3e17a4ec4f4cee0
react-native-alarm-notification: f27565d7b14c206fd1b0caac4aee82d2e7939b7d
react-native-alarm-notification: efb7227e85a0ae1ccd615fb81ce92510aaf740a9
react-native-camera: 3eae183c1d111103963f3dd913b65d01aef8110f
react-native-document-picker: b4f4a23b73f864ce17965b284c0757648993805b
react-native-fingerprint-scanner: ac6656f18c8e45a7459302b84da41a44ad96dbbe
@@ -790,7 +790,7 @@ SPEC CHECKSUMS:
react-native-image-resizer: 681f7607418b97c084ba2d0999b153b103040d8a
react-native-netinfo: 8a7fd3f7130ef4ad2fb4276d5c9f8d3f28d2df3d
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
react-native-saf-x: e0d30813901e54dad462dacda96555f725c2284f
react-native-saf-x: 672d22e9912d34e6a2c0ebcf7da2af67b2c177dc
react-native-safe-area-context: 0ee144a6170530ccc37a0fd9388e28d06f516a89
react-native-slider: ae9441a884f8465762f1f68d31eafad42663adb4
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
@@ -830,4 +830,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 3b2cace838120977b5b54871752c9dddf5a11cea
COCOAPODS: 1.12.1
COCOAPODS: 1.15.2

View File

@@ -3,7 +3,7 @@ import { Implementation as WindowImplementation } from '@joplin/lib/services/plu
import Setting from '@joplin/lib/models/Setting';
import { reg } from '@joplin/lib/registry';
import BasePlatformImplementation, { Joplin } from '@joplin/lib/services/plugins/BasePlatformImplementation';
import { Implementation as ImagingImplementation } from '@joplin/lib/services/plugins/api/JoplinImaging';
import { CreateFromPdfOptions, Implementation as ImagingImplementation } from '@joplin/lib/services/plugins/api/JoplinImaging';
import RNVersionInfo from 'react-native-version-info';
import { _ } from '@joplin/lib/locale';
import shim from '@joplin/lib/shim';
@@ -76,7 +76,12 @@ export default class PlatformImplementation extends BasePlatformImplementation {
public get imaging(): ImagingImplementation {
return {
nativeImage: null,
createFromPath: async (_path: string) => {
throw new Error('Not implemented: createFromPath');
},
createFromPdf: (_path: string, _options: CreateFromPdfOptions) => {
throw new Error('Not implemented: createFromPdf');
},
getPdfInfo: async () => {
throw new Error('Not implemented: getPdfInfo');
},

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