1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-20 23:30:05 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Laurent Cozic
c44aad544e update 2025-11-06 17:54:15 +01:00
468 changed files with 5461 additions and 32047 deletions

View File

@@ -6,7 +6,6 @@ _releases/
*.min.js
**/commands/index.ts
**/node_modules/
**/abcjs-basic-min.js
packages/generator-joplin/generators/app/templates/api/
Assets/
docs/
@@ -97,7 +96,7 @@ packages/onenote-converter/renderer/pkg/*
packages/app-cli/app/LinkSelector.js
packages/app-cli/app/app.js
packages/app-cli/app/base-command.js
packages/app-cli/app/cli-integration-tests.test.js
packages/app-cli/app/cli-integration-tests.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
@@ -165,6 +164,8 @@ packages/app-desktop/app.reducer.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/convertNoteToMarkdown.test.js
packages/app-desktop/commands/convertNoteToMarkdown.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/editProfileConfig.js
@@ -181,7 +182,6 @@ packages/app-desktop/commands/openProfileDirectory.js
packages/app-desktop/commands/openSecondaryAppInstance.js
packages/app-desktop/commands/replaceMisspelling.js
packages/app-desktop/commands/restoreNoteRevision.js
packages/app-desktop/commands/showProfileEditor.js
packages/app-desktop/commands/startExternalEditing.js
packages/app-desktop/commands/stopExternalEditing.js
packages/app-desktop/commands/switchProfile.js
@@ -206,6 +206,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
@@ -391,7 +392,6 @@ packages/app-desktop/gui/PopupNotification/NotificationItem.js
packages/app-desktop/gui/PopupNotification/PopupNotificationList.js
packages/app-desktop/gui/PopupNotification/PopupNotificationProvider.js
packages/app-desktop/gui/PopupNotification/types.js
packages/app-desktop/gui/ProfileEditor.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
@@ -424,11 +424,10 @@ 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/useOnItemClick.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.js
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndexes.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/hooks/utils/toggleHeader.js
@@ -469,7 +468,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
@@ -512,7 +510,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
@@ -564,7 +561,6 @@ packages/app-desktop/integration-tests/util/evaluateWithRetry.js
packages/app-desktop/integration-tests/util/extendedExpect.js
packages/app-desktop/integration-tests/util/getImageSourceSize.js
packages/app-desktop/integration-tests/util/getMainWindow.js
packages/app-desktop/integration-tests/util/mockClipboard.js
packages/app-desktop/integration-tests/util/retryOnFailure.js
packages/app-desktop/integration-tests/util/setDarkMode.js
packages/app-desktop/integration-tests/util/setFilePickerResponse.js
@@ -853,7 +849,6 @@ packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchBar.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
@@ -1010,7 +1005,6 @@ packages/editor/CodeMirror/CodeMirrorControl.js
packages/editor/CodeMirror/configFromSettings.js
packages/editor/CodeMirror/createEditor.test.js
packages/editor/CodeMirror/createEditor.js
packages/editor/CodeMirror/editorCommands/cutOrCopyText.js
packages/editor/CodeMirror/editorCommands/duplicateLine.test.js
packages/editor/CodeMirror/editorCommands/duplicateLine.js
packages/editor/CodeMirror/editorCommands/editorCommands.js
@@ -1030,7 +1024,6 @@ packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
packages/editor/CodeMirror/extensions/editorSettingsExtension.js
packages/editor/CodeMirror/extensions/highlightActiveLineExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
@@ -1054,7 +1047,6 @@ packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
@@ -1121,12 +1113,10 @@ packages/editor/ProseMirror/plugins/detailsPlugin.js
packages/editor/ProseMirror/plugins/imagePlugin.test.js
packages/editor/ProseMirror/plugins/imagePlugin.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
@@ -1141,7 +1131,6 @@ packages/editor/ProseMirror/schema.js
packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js
packages/editor/ProseMirror/testing/mockEditorApi.js
packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/SelectableNodeView.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
@@ -1155,8 +1144,6 @@ packages/editor/ProseMirror/utils/dom/showModal.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.js
packages/editor/ProseMirror/utils/getTextBetween.js
packages/editor/ProseMirror/utils/insertRenderedMarkdown.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
@@ -1246,8 +1233,6 @@ packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/convertNoteToMarkdown.test.js
packages/lib/commands/convertNoteToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
@@ -1376,7 +1361,6 @@ packages/lib/models/utils/getCanBeCollapsedFolderIds.js
packages/lib/models/utils/getCollator.js
packages/lib/models/utils/getConflictFolderId.js
packages/lib/models/utils/isItemId.js
packages/lib/models/utils/isJoplinServerVariant.js
packages/lib/models/utils/itemCanBeEncrypted.js
packages/lib/models/utils/onFolderDrop.test.js
packages/lib/models/utils/onFolderDrop.js
@@ -1412,7 +1396,6 @@ packages/lib/services/KeymapService_keysRegExp.js
packages/lib/services/KvStore.js
packages/lib/services/MigrationService.js
packages/lib/services/NavService.js
packages/lib/services/NotePositionService.js
packages/lib/services/PostMessageService.js
packages/lib/services/ReportService.test.js
packages/lib/services/ReportService.js
@@ -1428,7 +1411,6 @@ packages/lib/services/UndoRedoService.js
packages/lib/services/WhenClause.test.js
packages/lib/services/WhenClause.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
packages/lib/services/commands/ToolbarButtonUtils.js
packages/lib/services/commands/commandsToMarkdownTable.js
packages/lib/services/commands/focusEditorIfEditorCommand.js
@@ -1797,7 +1779,6 @@ packages/renderer/MdToHtml/createEventHandlingAttrs.js
packages/renderer/MdToHtml/linkReplacement.test.js
packages/renderer/MdToHtml/linkReplacement.js
packages/renderer/MdToHtml/renderMedia.js
packages/renderer/MdToHtml/rules/abc.js
packages/renderer/MdToHtml/rules/checkbox.js
packages/renderer/MdToHtml/rules/code_inline.js
packages/renderer/MdToHtml/rules/fence.js
@@ -1840,24 +1821,19 @@ packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/doRandomAction.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/ProgressBar.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/logDiffDebug.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/randomString.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js
packages/tools/generate-images.js
packages/tools/git-changelog.test.js
packages/tools/git-changelog.js
packages/tools/licenses/buildReport.js
packages/tools/licenses/getLicenses.js
packages/tools/licenses/licenseChecker.js
packages/tools/licenses/licenseOverrides/fontAwesomeOverride/index.js

View File

@@ -21,24 +21,19 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
node-version: '18'
cache: 'yarn'
- uses: dtolnay/rust-toolchain@stable
- name: Install Yarn
run: |
corepack enable
- name: Install
run: yarn install
env:
SKIP_ONENOTE_CONVERTER_BUILD: 1
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /opt/ghc || true
- name: Assemble Android Release
run: |

View File

@@ -9,9 +9,11 @@ jobs:
- uses: actions/checkout@v4
- uses: olegtarasov/get-tag@v2.1.4
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
# We need to pin the version to 18.15, because 18.16+ fails with this error:
# https://github.com/facebook/react-native/issues/36440
node-version: '18.20.8'
cache: 'yarn'
- name: Install Yarn

View File

@@ -147,9 +147,9 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
node-version: '18'
- name: Free disk space
if: runner.os == 'Linux'

View File

@@ -51,9 +51,9 @@ runs:
- uses: dtolnay/rust-toolchain@stable
if: ${{ runner.os != 'Windows' }}
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
node-version: '18.20.8'
# Disable the cache on ARM runners. For now, we don't run "yarn install" on these
# environments and this breaks actions/setup-node.
# See https://github.com/laurent22/joplin/commit/47d0d3eb9e89153a609fb5441344da10904c6308#commitcomment-159577783.

37
.gitignore vendored
View File

@@ -69,7 +69,7 @@ docs/**/*.mustache
packages/app-cli/app/LinkSelector.js
packages/app-cli/app/app.js
packages/app-cli/app/base-command.js
packages/app-cli/app/cli-integration-tests.test.js
packages/app-cli/app/cli-integration-tests.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
@@ -137,6 +137,8 @@ packages/app-desktop/app.reducer.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/convertNoteToMarkdown.test.js
packages/app-desktop/commands/convertNoteToMarkdown.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/editProfileConfig.js
@@ -153,7 +155,6 @@ packages/app-desktop/commands/openProfileDirectory.js
packages/app-desktop/commands/openSecondaryAppInstance.js
packages/app-desktop/commands/replaceMisspelling.js
packages/app-desktop/commands/restoreNoteRevision.js
packages/app-desktop/commands/showProfileEditor.js
packages/app-desktop/commands/startExternalEditing.js
packages/app-desktop/commands/stopExternalEditing.js
packages/app-desktop/commands/switchProfile.js
@@ -178,6 +179,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
@@ -363,7 +365,6 @@ packages/app-desktop/gui/PopupNotification/NotificationItem.js
packages/app-desktop/gui/PopupNotification/PopupNotificationList.js
packages/app-desktop/gui/PopupNotification/PopupNotificationProvider.js
packages/app-desktop/gui/PopupNotification/types.js
packages/app-desktop/gui/ProfileEditor.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
@@ -396,11 +397,10 @@ 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/useOnItemClick.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.js
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndexes.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/hooks/utils/toggleHeader.js
@@ -441,7 +441,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
@@ -484,7 +483,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
@@ -536,7 +534,6 @@ packages/app-desktop/integration-tests/util/evaluateWithRetry.js
packages/app-desktop/integration-tests/util/extendedExpect.js
packages/app-desktop/integration-tests/util/getImageSourceSize.js
packages/app-desktop/integration-tests/util/getMainWindow.js
packages/app-desktop/integration-tests/util/mockClipboard.js
packages/app-desktop/integration-tests/util/retryOnFailure.js
packages/app-desktop/integration-tests/util/setDarkMode.js
packages/app-desktop/integration-tests/util/setFilePickerResponse.js
@@ -825,7 +822,6 @@ packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchBar.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
@@ -982,7 +978,6 @@ packages/editor/CodeMirror/CodeMirrorControl.js
packages/editor/CodeMirror/configFromSettings.js
packages/editor/CodeMirror/createEditor.test.js
packages/editor/CodeMirror/createEditor.js
packages/editor/CodeMirror/editorCommands/cutOrCopyText.js
packages/editor/CodeMirror/editorCommands/duplicateLine.test.js
packages/editor/CodeMirror/editorCommands/duplicateLine.js
packages/editor/CodeMirror/editorCommands/editorCommands.js
@@ -1002,7 +997,6 @@ packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
packages/editor/CodeMirror/extensions/editorSettingsExtension.js
packages/editor/CodeMirror/extensions/highlightActiveLineExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
@@ -1026,7 +1020,6 @@ packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
@@ -1093,12 +1086,10 @@ packages/editor/ProseMirror/plugins/detailsPlugin.js
packages/editor/ProseMirror/plugins/imagePlugin.test.js
packages/editor/ProseMirror/plugins/imagePlugin.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
@@ -1113,7 +1104,6 @@ packages/editor/ProseMirror/schema.js
packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js
packages/editor/ProseMirror/testing/mockEditorApi.js
packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/SelectableNodeView.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
@@ -1127,8 +1117,6 @@ packages/editor/ProseMirror/utils/dom/showModal.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.js
packages/editor/ProseMirror/utils/getTextBetween.js
packages/editor/ProseMirror/utils/insertRenderedMarkdown.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
@@ -1218,8 +1206,6 @@ packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/convertNoteToMarkdown.test.js
packages/lib/commands/convertNoteToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
@@ -1348,7 +1334,6 @@ packages/lib/models/utils/getCanBeCollapsedFolderIds.js
packages/lib/models/utils/getCollator.js
packages/lib/models/utils/getConflictFolderId.js
packages/lib/models/utils/isItemId.js
packages/lib/models/utils/isJoplinServerVariant.js
packages/lib/models/utils/itemCanBeEncrypted.js
packages/lib/models/utils/onFolderDrop.test.js
packages/lib/models/utils/onFolderDrop.js
@@ -1384,7 +1369,6 @@ packages/lib/services/KeymapService_keysRegExp.js
packages/lib/services/KvStore.js
packages/lib/services/MigrationService.js
packages/lib/services/NavService.js
packages/lib/services/NotePositionService.js
packages/lib/services/PostMessageService.js
packages/lib/services/ReportService.test.js
packages/lib/services/ReportService.js
@@ -1400,7 +1384,6 @@ packages/lib/services/UndoRedoService.js
packages/lib/services/WhenClause.test.js
packages/lib/services/WhenClause.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
packages/lib/services/commands/ToolbarButtonUtils.js
packages/lib/services/commands/commandsToMarkdownTable.js
packages/lib/services/commands/focusEditorIfEditorCommand.js
@@ -1769,7 +1752,6 @@ packages/renderer/MdToHtml/createEventHandlingAttrs.js
packages/renderer/MdToHtml/linkReplacement.test.js
packages/renderer/MdToHtml/linkReplacement.js
packages/renderer/MdToHtml/renderMedia.js
packages/renderer/MdToHtml/rules/abc.js
packages/renderer/MdToHtml/rules/checkbox.js
packages/renderer/MdToHtml/rules/code_inline.js
packages/renderer/MdToHtml/rules/fence.js
@@ -1812,24 +1794,19 @@ packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/doRandomAction.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/ProgressBar.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/logDiffDebug.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/randomString.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js
packages/tools/generate-images.js
packages/tools/git-changelog.test.js
packages/tools/git-changelog.js
packages/tools/licenses/buildReport.js
packages/tools/licenses/getLicenses.js
packages/tools/licenses/licenseChecker.js
packages/tools/licenses/licenseOverrides/fontAwesomeOverride/index.js

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -2,7 +2,7 @@
# Build stage
# =============================================================================
FROM node:24 AS builder
FROM node:18 AS builder
RUN apt-get update \
&& apt-get install -y \
@@ -58,7 +58,7 @@ RUN --mount=type=cache,target=/build/.yarn/cache --mount=type=cache,target=/buil
# from a smaller base image.
# =============================================================================
FROM node:24-slim
FROM node:18-slim
ARG user=joplin
RUN useradd --create-home --shell /bin/bash $user

View File

@@ -1,4 +1,4 @@
FROM node:24-bullseye
FROM node:18-bullseye
RUN apt-get update \
&& apt-get install -y \

View File

@@ -67,45 +67,6 @@ showHelp() {
fi
}
# Accepts two versions in symver (a.b.c).
# Echos -1 if the first version is less than the second,
# 0 if they're equal,
# 1 if the first version is greater than second.
compareVersions() {
V_MAJOR1=$(echo "$1"|cut -d. -f1)
V_MAJOR2=$(echo "$2"|cut -d. -f1)
if [[ $V_MAJOR1 -lt $V_MAJOR2 ]] ; then
echo -1
return
elif [[ $V_MAJOR1 -gt $V_MAJOR2 ]] ; then
echo 1
return
fi
V_MINOR1=$(echo "$1"|cut -d. -f2)
V_MINOR2=$(echo "$2"|cut -d. -f2)
if [[ $V_MINOR1 -lt $V_MINOR2 ]] ; then
echo -1
return
elif [[ $V_MINOR1 -gt $V_MINOR2 ]] ; then
echo 1
return
fi
V_PATCH1=$(echo "$1"|cut -d. -f3)
V_PATCH2=$(echo "$2"|cut -d. -f3)
if [[ $V_PATCH1 -lt $V_PATCH2 ]] ; then
echo -1
elif [[ $V_PATCH1 -gt $V_PATCH2 ]] ; then
echo 1
else
echo 0
fi
}
#-----------------------------------------------------
# Setup Download Helper: DL
#-----------------------------------------------------
@@ -198,21 +159,12 @@ else
fi
# Check if it's in the latest version
if [[ -e "${INSTALL_DIR}/VERSION" ]]; then
CURRENT_VERSION=$(< "${INSTALL_DIR}/VERSION")
VERSION_COMPARISON=$(compareVersions "$CURRENT_VERSION" "$RELEASE_VERSION")
if [[ "$VERSION_COMPARISON" == "0" ]]; then
print "${COLOR_GREEN}You already have the latest version${COLOR_RESET} ${RELEASE_VERSION} ${COLOR_GREEN}installed.${COLOR_RESET}"
([[ "$FORCE" == true ]] && print "Forcing installation...") || exit 0
elif [[ "$VERSION_COMPARISON" == "1" ]]; then
print "${COLOR_YELLOW}You have version ${CURRENT_VERSION} installed, which is newer than the latest published version ${RELEASE_VERSION}.${COLOR_RESET}"
print "${COLOR_YELLOW}Skipping installation to avoid downgrade.${COLOR_RESET}"
else
print "The latest version is ${RELEASE_VERSION}, but you have ${CURRENT_VERSION} installed."
fi
if [[ -e "${INSTALL_DIR}/VERSION" ]] && [[ $(< "${INSTALL_DIR}/VERSION") == "${RELEASE_VERSION}" ]]; then
print "${COLOR_GREEN}You already have the latest version${COLOR_RESET} ${RELEASE_VERSION} ${COLOR_GREEN}installed.${COLOR_RESET}"
([[ "$FORCE" == true ]] && print "Forcing installation...") || exit 0
else
print "The latest version is ${RELEASE_VERSION}, but you have no version installed."
[[ -e "${INSTALL_DIR}/VERSION" ]] && CURRENT_VERSION=$(< "${INSTALL_DIR}/VERSION")
print "The latest version is ${RELEASE_VERSION}, but you have ${CURRENT_VERSION:-no version} installed."
fi
# Check if it's an update or a new install
@@ -284,7 +236,7 @@ if command -v lsb_release &> /dev/null; then
# 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/.
HAS_USERNS_RESTRICTIONS=false
if [[ "$DISTVER" =~ ^(Ubuntu|Tuxedo) && $DISTMAJOR -ge 23 ]]; then
if [[ "$DISTVER" =~ ^Ubuntu && $DISTMAJOR -ge 23 ]]; then
HAS_USERNS_RESTRICTIONS=true
fi
@@ -306,15 +258,6 @@ fi
if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cinnamon.*|.*deepin.*|.*pantheon.*|.*lxde.*|.*i3.*|.*sway.* ]] || [[ `command -v update-desktop-database` ]]; then
DATA_HOME=${XDG_DATA_HOME:-~/.local/share}
DESKTOP_FILE_LOCATION="$DATA_HOME/applications"
# Only later versions of Joplin default to Wayland
IS_WAYLAND_BY_DEFAULT=$(compareVersions "$RELEASE_VERSION" "3.5.6")
# Joplin has a different startup WM class on Wayland and X11:
STARTUP_WM_CLASS=Joplin
if [[ $XDG_SESSION_TYPE != "x11" && $IS_WAYLAND_BY_DEFAULT == "1" ]]; then
STARTUP_WM_CLASS=@joplin/app-desktop
fi
# Only delete the desktop file if it will be replaced
rm -f "$DESKTOP_FILE_LOCATION/appimagekit-joplin.desktop"
@@ -329,9 +272,7 @@ Name=Joplin
Comment=Joplin for Desktop
Exec=env APPIMAGELAUNCHER_DISABLE=TRUE "${INSTALL_DIR}/Joplin.AppImage" ${SANDBOXPARAM} %u
Icon=joplin
# This will be different between Wayland and X11. On Wayland, the startup
# WM class is "@joplin/app-desktop". On X11, it's "Joplin".
StartupWMClass=${STARTUP_WM_CLASS}
StartupWMClass=Joplin
Type=Application
Categories=Office;
MimeType=x-scheme-handler/joplin;

View File

@@ -1,5 +1,5 @@
<!-- DONATELINKS -->
[![Donate using PayPal](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-PayPal-green.svg)](https://www.paypal.com/donate/?hosted_button_id=WQCERTSSLCC7U) [![Sponsor on GitHub](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/GitHub-Badge.svg)](https://github.com/sponsors/laurent22/) [![Become a patron](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Patreon-Badge.svg)](https://www.patreon.com/joplin) [![Donate using IBAN](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-IBAN.svg)](https://joplinapp.org/donate/#donations)
[![Donate using PayPal](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-PayPal-green.svg)](https://www.paypal.com/donate/?business=E8JMYD2LQ8MMA&no_recurring=0&item_name=I+rely+on+donations+to+maintain+and+improve+the+Joplin+open+source+project.+Thank+you+for+your+help+-+it+makes+a+difference%21&currency_code=EUR) [![Sponsor on GitHub](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/GitHub-Badge.svg)](https://github.com/sponsors/laurent22/) [![Become a patron](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Patreon-Badge.svg)](https://www.patreon.com/joplin) [![Donate using IBAN](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/badges/Donate-IBAN.svg)](https://joplinapp.org/donate/#donations)
<!-- DONATELINKS -->
<img width="64" src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png" align="left" style="margin-right:15px"/>

View File

@@ -9,7 +9,7 @@
"vips.dev": {
"platforms": ["aarch64-darwin"],
},
"nodejs": "24.5.0",
"nodejs": "23.11.0",
"pkg-config": "latest",
"darwin.apple_sdk.frameworks.Foundation": { // satisfies missing CoreText/CoreText.h
// https://github.com/NixOS/nixpkgs/blob/master/pkgs/os-specific/darwin/apple-sdk/default.nix
@@ -22,7 +22,7 @@
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "2.50.1",
"git": "2.48.1",
},
"shell": {
"init_hook": [

View File

@@ -19,7 +19,7 @@
services:
postgresql-master:
image: 'bitnamilegacy/postgresql:17.5.0'
image: 'bitnamilegacy/postgresql:17.4.0'
ports:
- '5432:5432'
environment:
@@ -36,7 +36,7 @@ services:
- POSTGRESQL_EXTRA_FLAGS=-c work_mem=100000 -c log_statement=all
postgresql-slave:
image: 'bitnamilegacy/postgresql:17.5.0'
image: 'bitnamilegacy/postgresql:17.4.0'
ports:
- '5433:5432'
depends_on:

View File

@@ -81,12 +81,12 @@
"eslint-plugin-promise": "6.6.0",
"eslint-plugin-react": "7.37.5",
"execa": "5.1.1",
"fs-extra": "11.3.2",
"fs-extra": "11.2.0",
"glob": "11.0.3",
"gulp": "4.0.2",
"husky": "9.1.7",
"lerna": "3.22.1",
"lint-staged": "16.1.6",
"lint-staged": "15.5.2",
"madge": "8.0.0",
"npm-package-json-lint": "8.0.0",
"typescript": "5.8.3"
@@ -95,7 +95,7 @@
"@types/fs-extra": "11.0.4",
"eslint-plugin-github": "4.10.2",
"http-server": "14.1.1",
"node-gyp": "11.4.2",
"node-gyp": "11.2.0",
"nodemon": "3.1.10"
},
"packageManager": "yarn@4.9.2",

View File

@@ -1,270 +0,0 @@
import * as fs from 'fs-extra';
import Logger, { TargetType } from '@joplin/utils/Logger';
import { dirname } from '@joplin/lib/path-utils';
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import BaseModel from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { node } from 'execa';
import { splitCommandString } from '@joplin/utils';
const nodeSqlite = require('sqlite3');
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
const { default: shimInitCli } = require('./utils/shimInitCli');
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
const joplinAppPath = `${__dirname}/main.js`;
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null });
require('@joplin/lib/testing/test-utils');
interface Client {
id: number;
profileDir: string;
}
function createClient(id: number): Client {
return {
id: id,
profileDir: `${baseDir}/client${id}`,
};
}
async function execCommand(client: Client, command: string) {
const result = await node(
joplinAppPath,
['--update-geolocation-disabled', '--env', 'dev', '--profile', client.profileDir, ...splitCommandString(command)],
);
if (result.exitCode !== 0) {
throw new Error(`Command failed: ${command}:\nstderr: ${result.stderr}\nstdout: ${result.stdout}`);
}
return result.stdout;
}
async function clearDatabase(db: JoplinDatabase) {
await db.transactionExecBatch(['DELETE FROM folders', 'DELETE FROM notes', 'DELETE FROM tags', 'DELETE FROM note_tags', 'DELETE FROM resources', 'DELETE FROM deleted_items']);
}
describe('cli-integration-tests', () => {
let client: Client;
let db: JoplinDatabase;
beforeAll(async () => {
await fs.remove(baseDir);
await fs.mkdir(baseDir);
client = createClient(1);
// Initialize the database by running a client command and exiting.
await execCommand(client, 'version');
const dbLogger = new Logger();
dbLogger.addTarget(TargetType.Console);
dbLogger.setLevel(Logger.LEVEL_WARN);
db = new JoplinDatabase(new DatabaseDriverNode());
db.setLogger(dbLogger);
await db.open({ name: `${client.profileDir}/database.sqlite` });
BaseModel.setDb(db);
Setting.setConstant('rootProfileDir', client.profileDir);
Setting.setConstant('profileDir', client.profileDir);
await loadKeychainServiceAndSettings([]);
});
beforeEach(async () => {
await clearDatabase(db);
});
it.each([
'version',
'help',
])('should run command %j without crashing', async (command) => {
await execCommand(client, command);
});
it('should support the \'ls\' command', async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote note1');
await execCommand(client, 'mknote note2');
const r = await execCommand(client, 'ls');
expect(r.indexOf('note1') >= 0).toBe(true);
expect(r.indexOf('note2') >= 0).toBe(true);
});
it('should support the \'mv\' command', async () => {
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
await execCommand(client, 'mv n1 nb2');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes1 = await Note.previews(f1.id);
let notes2 = await Note.previews(f2.id);
expect(notes1.length).toBe(0);
expect(notes2.length).toBe(1);
await execCommand(client, 'mknote note1');
await execCommand(client, 'mknote note2');
await execCommand(client, 'mknote note3');
await execCommand(client, 'mknote blabla');
notes1 = await Note.previews(f1.id);
notes2 = await Note.previews(f2.id);
expect(notes1.length).toBe(4);
expect(notes2.length).toBe(1);
await execCommand(client, 'mv \'note*\' nb2');
notes2 = await Note.previews(f2.id);
notes1 = await Note.previews(f1.id);
expect(notes1.length).toBe(1);
expect(notes2.length).toBe(4);
});
it('should support the \'use\' command', async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mknote n1');
await execCommand(client, 'mknote n2');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes1 = await Note.previews(f1.id);
let notes2 = await Note.previews(f2.id);
expect(notes1.length).toBe(0);
expect(notes2.length).toBe(2);
await execCommand(client, 'use nb1');
await execCommand(client, 'mknote note2');
await execCommand(client, 'mknote note3');
notes1 = await Note.previews(f1.id);
notes2 = await Note.previews(f2.id);
expect(notes1.length).toBe(2);
});
it('should support creating and removing folders', async () => {
await execCommand(client, 'mkbook nb1');
let folders = await Folder.all();
expect(folders.length).toBe(1);
expect(folders[0].title).toBe('nb1');
await execCommand(client, 'mkbook nb1');
folders = await Folder.all();
expect(folders.length).toBe(2);
expect(folders[0].title).toBe('nb1');
expect(folders[1].title).toBe('nb1');
await execCommand(client, 'rmbook -p -f nb1');
folders = await Folder.all();
expect(folders.length).toBe(1);
await execCommand(client, 'rmbook -p -f nb1');
folders = await Folder.all();
expect(folders.length).toBe(0);
});
it('should support creating and removing notes', async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
let notes = await Note.all();
expect(notes.length).toBe(1);
expect(notes[0].title).toBe('n1');
await execCommand(client, 'rmnote -p -f n1');
notes = await Note.all();
expect(notes.length).toBe(0);
await execCommand(client, 'mknote n1');
await execCommand(client, 'mknote n2');
notes = await Note.all();
expect(notes.length).toBe(2);
// Should fail to delete a non-existent note
let failed = false;
try {
await execCommand(client, 'rmnote -f \'blabla*\'');
} catch (error) {
failed = true;
}
expect(failed).toBe(true);
notes = await Note.all();
expect(notes.length).toBe(2);
await execCommand(client, 'rmnote -f -p \'n*\'');
notes = await Note.all();
expect(notes.length).toBe(0);
});
it('should support listing the contents of notes', async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote mynote');
const folder = await Folder.loadByTitle('nb1');
const note = await Note.loadFolderNoteByField(folder.id, 'title', 'mynote');
let r = await execCommand(client, 'cat mynote');
expect(r).toContain('mynote');
expect(r).not.toContain(note.id);
r = await execCommand(client, 'cat -v mynote');
expect(r).toContain(note.id);
});
it('should support changing settings with config', async () => {
await execCommand(client, 'config editor vim');
await Setting.reset();
await Setting.load();
expect(Setting.value('editor')).toBe('vim');
await execCommand(client, 'config editor subl');
await Setting.reset();
await Setting.load();
expect(Setting.value('editor')).toBe('subl');
const r = await execCommand(client, 'config');
expect(r.indexOf('editor') >= 0).toBe(true);
expect(r.indexOf('subl') >= 0).toBe(true);
});
it('should support copying folders with cp', async () => {
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
await execCommand(client, 'cp n1');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes = await Note.previews(f1.id);
expect(notes.length).toBe(2);
await execCommand(client, 'cp n1 nb2');
const notesF1 = await Note.previews(f1.id);
expect(notesF1.length).toBe(2);
notes = await Note.previews(f2.id);
expect(notes.length).toBe(1);
expect(notes[0].title).toBe(notesF1[0].title);
});
});

View File

@@ -0,0 +1,300 @@
'use strict';
/* eslint-disable no-console */
import * as fs from 'fs-extra';
import Logger, { TargetType } from '@joplin/utils/Logger';
import { dirname } from '@joplin/lib/path-utils';
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import BaseModel from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
const { sprintf } = require('sprintf-js');
const exec = require('child_process').exec;
const nodeSqlite = require('sqlite3');
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
const { default: shimInitCli } = require('./utils/shimInitCli');
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
const joplinAppPath = `${__dirname}/main.js`;
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null });
require('@joplin/lib/testing/test-utils');
const logger = new Logger();
logger.addTarget(TargetType.Console);
logger.setLevel(Logger.LEVEL_ERROR);
const dbLogger = new Logger();
dbLogger.addTarget(TargetType.Console);
dbLogger.setLevel(Logger.LEVEL_INFO);
const db = new JoplinDatabase(new DatabaseDriverNode());
db.setLogger(dbLogger);
interface Client {
id: number;
profileDir: string;
}
function createClient(id: number): Client {
return {
id: id,
profileDir: `${baseDir}/client${id}`,
};
}
const client = createClient(1);
function execCommand(client: Client, command: string) {
const exePath = `node ${joplinAppPath}`;
const cmd = `${exePath} --update-geolocation-disabled --env dev --profile ${client.profileDir} ${command}`;
logger.info(`${client.id}: ${command}`);
return new Promise<string>((resolve, reject) => {
exec(cmd, (error: string, stdout: string, stderr: string) => {
if (error) {
logger.error(stderr);
reject(error);
} else {
resolve(stdout.trim());
}
});
});
}
function assertTrue(v: unknown) {
if (!v) throw new Error(sprintf('Expected "true", got "%s"."', v));
process.stdout.write('.');
}
function assertFalse(v: unknown) {
if (v) throw new Error(sprintf('Expected "false", got "%s"."', v));
process.stdout.write('.');
}
function assertEquals(expected: unknown, real: unknown) {
if (expected !== real) throw new Error(sprintf('Expecting "%s", got "%s"', expected, real));
process.stdout.write('.');
}
async function clearDatabase() {
await db.transactionExecBatch(['DELETE FROM folders', 'DELETE FROM notes', 'DELETE FROM tags', 'DELETE FROM note_tags', 'DELETE FROM resources', 'DELETE FROM deleted_items']);
}
const testUnits: Record<string, ()=> Promise<void>> = {};
testUnits.testFolders = async () => {
await execCommand(client, 'mkbook nb1');
let folders = await Folder.all();
assertEquals(1, folders.length);
assertEquals('nb1', folders[0].title);
await execCommand(client, 'mkbook nb1');
folders = await Folder.all();
assertEquals(2, folders.length);
assertEquals('nb1', folders[0].title);
assertEquals('nb1', folders[1].title);
await execCommand(client, 'rmbook -p -f nb1');
folders = await Folder.all();
assertEquals(1, folders.length);
await execCommand(client, 'rmbook -p -f nb1');
folders = await Folder.all();
assertEquals(0, folders.length);
};
testUnits.testNotes = async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
let notes = await Note.all();
assertEquals(1, notes.length);
assertEquals('n1', notes[0].title);
await execCommand(client, 'rmnote -p -f n1');
notes = await Note.all();
assertEquals(0, notes.length);
await execCommand(client, 'mknote n1');
await execCommand(client, 'mknote n2');
notes = await Note.all();
assertEquals(2, notes.length);
// Should fail to delete a non-existent note
let failed = false;
try {
await execCommand(client, 'rmnote -f \'blabla*\'');
} catch (error) {
failed = true;
}
assertEquals(failed, true);
notes = await Note.all();
assertEquals(2, notes.length);
await execCommand(client, 'rmnote -f -p \'n*\'');
notes = await Note.all();
assertEquals(0, notes.length);
};
testUnits.testCat = async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote mynote');
const folder = await Folder.loadByTitle('nb1');
const note = await Note.loadFolderNoteByField(folder.id, 'title', 'mynote');
let r = await execCommand(client, 'cat mynote');
assertTrue(r.indexOf('mynote') >= 0);
assertFalse(r.indexOf(note.id) >= 0);
r = await execCommand(client, 'cat -v mynote');
assertTrue(r.indexOf(note.id) >= 0);
};
testUnits.testConfig = async () => {
await execCommand(client, 'config editor vim');
await Setting.reset();
await Setting.load();
assertEquals('vim', Setting.value('editor'));
await execCommand(client, 'config editor subl');
await Setting.reset();
await Setting.load();
assertEquals('subl', Setting.value('editor'));
const r = await execCommand(client, 'config');
assertTrue(r.indexOf('editor') >= 0);
assertTrue(r.indexOf('subl') >= 0);
};
testUnits.testCp = async () => {
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
await execCommand(client, 'cp n1');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes = await Note.previews(f1.id);
assertEquals(2, notes.length);
await execCommand(client, 'cp n1 nb2');
const notesF1 = await Note.previews(f1.id);
assertEquals(2, notesF1.length);
notes = await Note.previews(f2.id);
assertEquals(1, notes.length);
assertEquals(notesF1[0].title, notes[0].title);
};
testUnits.testLs = async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote note1');
await execCommand(client, 'mknote note2');
const r = await execCommand(client, 'ls');
assertTrue(r.indexOf('note1') >= 0);
assertTrue(r.indexOf('note2') >= 0);
};
testUnits.testMv = async () => {
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mknote n1');
await execCommand(client, 'mv n1 nb2');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes1 = await Note.previews(f1.id);
let notes2 = await Note.previews(f2.id);
assertEquals(0, notes1.length);
assertEquals(1, notes2.length);
await execCommand(client, 'mknote note1');
await execCommand(client, 'mknote note2');
await execCommand(client, 'mknote note3');
await execCommand(client, 'mknote blabla');
notes1 = await Note.previews(f1.id);
notes2 = await Note.previews(f2.id);
assertEquals(4, notes1.length);
assertEquals(1, notes2.length);
await execCommand(client, 'mv \'note*\' nb2');
notes2 = await Note.previews(f2.id);
notes1 = await Note.previews(f1.id);
assertEquals(1, notes1.length);
assertEquals(4, notes2.length);
};
testUnits.testUse = async () => {
await execCommand(client, 'mkbook nb1');
await execCommand(client, 'mkbook nb2');
await execCommand(client, 'mknote n1');
await execCommand(client, 'mknote n2');
const f1 = await Folder.loadByTitle('nb1');
const f2 = await Folder.loadByTitle('nb2');
let notes1 = await Note.previews(f1.id);
let notes2 = await Note.previews(f2.id);
assertEquals(0, notes1.length);
assertEquals(2, notes2.length);
await execCommand(client, 'use nb1');
await execCommand(client, 'mknote note2');
await execCommand(client, 'mknote note3');
notes1 = await Note.previews(f1.id);
notes2 = await Note.previews(f2.id);
assertEquals(2, notes1.length);
assertEquals(2, notes2.length);
};
async function main() {
await fs.remove(baseDir);
logger.info(await execCommand(client, 'version'));
await db.open({ name: `${client.profileDir}/database.sqlite` });
BaseModel.setDb(db);
Setting.setConstant('rootProfileDir', client.profileDir);
Setting.setConstant('profileDir', client.profileDir);
await loadKeychainServiceAndSettings([]);
let onlyThisTest = 'testMv';
onlyThisTest = '';
for (const n in testUnits) {
if (!testUnits.hasOwnProperty(n)) continue;
if (onlyThisTest && n !== onlyThisTest) continue;
await clearDatabase();
const testName = n.substr(4).toLowerCase();
process.stdout.write(`${testName}: `);
await testUnits[n]();
console.info('');
}
}
main().catch(error => {
console.info('');
logger.error(error);
});

View File

@@ -107,7 +107,6 @@ class Command extends BaseCommand {
userContentBaseUrl: () => joplinServerAuth.userContentBaseUrl,
username: () => joplinServerAuth.email,
password: () => joplinServerAuth.password,
apiKey: () => '',
session: (): Session => null,
});

View File

@@ -12,7 +12,7 @@ class Command extends BaseCommand {
}
public override async action() {
this.stdout(versionInfo(require('../package.json'), {}).message);
this.stdout(versionInfo(require('./package.json'), {}).message);
}
}

View File

@@ -2,7 +2,6 @@ import app from '../app';
import Folder from '@joplin/lib/models/Folder';
import BaseCommand from '../base-command';
import setupCommand from '../setupCommand';
import Setting from '@joplin/lib/models/Setting';
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
export const setupCommandForTesting = (CommandClass: any, stdout: Function = null): BaseCommand => {
@@ -19,9 +18,4 @@ export const setupApplication = async () => {
// Some tests also need access to the Redux store
app().initRedux();
// Since the settings need to be loaded before the store is created, it will never
// receive the SETTING_UPDATE_ALL event, which means state.settings will not be
// initialised. So we manually call dispatchUpdateAll() to force an update.
Setting.dispatchUpdateAll();
};

View File

@@ -1,5 +1,5 @@
const { afterEachCleanUp } = require('@joplin/lib/testing/test-utils.js');
const { default: shimInitCli } = require('./app/utils/shimInitCli');
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const shim = require('@joplin/lib/shim').default;
const sharp = require('sharp');
const nodeSqlite = require('sqlite3');
@@ -13,7 +13,7 @@ try {
keytar = null;
}
shimInitCli({ sharp, nodeSqlite, appVersion: () => require('./package.json').version, keytar });
shimInit({ sharp, keytar, nodeSqlite });
global.afterEach(async () => {
await afterEachCleanUp();

View File

@@ -48,7 +48,7 @@
"chalk": "4.1.2",
"compare-version": "0.1.2",
"file-type": "16.5.4",
"fs-extra": "11.3.2",
"fs-extra": "11.2.0",
"html-entities": "1.4.0",
"keytar": "7.9.0",
"md5": "2.3.0",
@@ -57,7 +57,7 @@
"proper-lockfile": "4.1.2",
"redux": "4.2.1",
"server-destroy": "1.0.1",
"sharp": "0.34.4",
"sharp": "0.34.3",
"sprintf-js": "1.1.3",
"sqlite3": "5.1.6",
"string-padding": "1.0.2",

View File

@@ -52,7 +52,7 @@ describe('MarkupToHtml', () => {
pluginAssets: [],
};
expect(await service.render(MarkupLanguage.Html, testString, {}, { })).toMatchObject(expectedOutput);
expect(await service.render(MarkupLanguage.Markdown, testString, {}, { })).toMatchObject(expectedOutput);
expect(await service.render(MarkupLanguage.Html, testString, {}, {})).toMatchObject(expectedOutput);
expect(await service.render(MarkupLanguage.Markdown, testString, {}, {})).toMatchObject(expectedOutput);
});
});

View File

@@ -1,11 +0,0 @@
<ul>
<li><a href="https://example.com/" title="This
is a test title
testing!
Test...">Test!</a></li>
<li><a href="http://example.com" title="
Test
">Another test...</a></li>
</ul>

View File

@@ -1,5 +0,0 @@
- [Test!](https://example.com/ "This
is a test title
testing!
Test...")
- [Another test...](http://example.com "Test")

View File

@@ -1,74 +0,0 @@
<!-- From https://en.wikipedia.org/wiki/Collatz_conjecture -->
<math display="block" xmlns="http://www.w3.org/1998/Math/MathML" alttext="{\displaystyle f(n)={\begin{cases}n/2&amp;{\text{if }}n\equiv 0{\pmod {2}},\\3n+1&amp;{\text{if }}n\equiv 1{\pmod {2}}.\end{cases}}}">
<semantics>
<mrow class="MJX-TeXAtom-ORD">
<mstyle displaystyle="true" scriptlevel="0">
<mi>f</mi>
<mo stretchy="false">(</mo>
<mi>n</mi>
<mo stretchy="false">)</mo>
<mo>=</mo>
<mrow class="MJX-TeXAtom-ORD">
<mrow>
<mo>{</mo>
<mtable columnalign="left left" rowspacing=".2em" columnspacing="1em" displaystyle="false">
<mtr>
<mtd>
<mi>n</mi>
<mrow class="MJX-TeXAtom-ORD">
<mo>/</mo>
</mrow>
<mn>2</mn>
</mtd>
<mtd>
<mrow class="MJX-TeXAtom-ORD">
<mtext>if&nbsp;</mtext>
</mrow>
<mi>n</mi>
<mo>\u2261</mo>
<mn>0</mn>
<mrow class="MJX-TeXAtom-ORD">
<mspace width="0.444em"></mspace>
<mo stretchy="false">(</mo>
<mi>mod</mi>
<mspace width="0.333em"></mspace>
<mn>2</mn>
<mo stretchy="false">)</mo>
</mrow>
<mo>,</mo>
</mtd>
</mtr>
<mtr>
<mtd>
<mn>3</mn>
<mi>n</mi>
<mo>+</mo>
<mn>1</mn>
</mtd>
<mtd>
<mrow class="MJX-TeXAtom-ORD">
<mtext>if&nbsp;</mtext>
</mrow>
<mi>n</mi>
<mo>\u2261</mo>
<mn>1</mn>
<mrow class="MJX-TeXAtom-ORD">
<mspace width="0.444em"></mspace>
<mo stretchy="false">(</mo>
<mi>mod</mi>
<mspace width="0.333em"></mspace>
<mn>2</mn>
<mo stretchy="false">)</mo>
</mrow>
<mo>.</mo>
</mtd>
</mtr>
</mtable>
<mo fence="true" stretchy="true" symmetric="true"></mo>
</mrow>
</mrow>
</mstyle>
</mrow>
<annotation encoding="application/x-tex">{\displaystyle f(n)={\begin{cases}n/2&amp;{\text{if }}n\equiv 0{\pmod {2}},\\3n+1&amp;{\text{if }}n\equiv 1{\pmod {2}}.\end{cases}}}</annotation>
</semantics>
</math></span><img src="/some/src/here" class="mwe-math-fallback-image-display mw-invert skin-invert" aria-hidden="true" style="vertical-align: -3.171ex; width:45.735ex; height:7.509ex;"/>

View File

@@ -1 +0,0 @@
${\displaystyle f(n)={\begin{cases}n/2&{\text{if }}n\equiv 0{\pmod {2}},\\3n+1&{\text{if }}n\equiv 1{\pmod {2}}.\end{cases}}}$

View File

@@ -1,3 +0,0 @@
# test for joplin import
[https://l.facebook.com/l.php?u=https%3A%2F%2Fix.sk%2FNiBZH%3Futm\_source%3DYouTube%2520Instagram%26utm\_medium%3D2HIqFSGVVB2mFsVTJClrQ7ZnuGJaUt6hu1MNH0vUMjcrgWnUsK%26utm\_campaign%3D%25F0%259F%2598%25A9%25F0%259F%2598%258E%25F0%259F%2598%25BF%25F0%259F%25A4%2594%25F0%259F%2598%25A9%25F0%259F%2599%2583%25F0%259F%25A4%25AF%25F0%259F%25A5%25B0%25F0%259F%2598%25AB%25F0%259F%2598%25BA%26utm\_id%3D%25F0%259F%2598%258B%25F0%259F%2598%25A5%25F0%259F%25A4%25A1%25F0%259F%2598%25A0%25F0%259F%2598%2587%25F0%259F%25A5%25B4%25F0%259F%25A7%2590%25F0%259F%2598%258E%25F0%259F%2598%2582%25F0%259F%2598%259E%26utm\_term%3D%25F0%259F%2598%2584%25F0%259F%25A4%25A9%25F0%259F%2599%2580%25F0%259F%2598%2593%25F0%259F%25A4%25AF%25F0%259F%25A4%25A5%25F0%259F%2591%25BE%25F0%259F%2591%25BF%25F0%259F%2598%25BD%25F0%259F%25A4%25A5%26utm\_content%3D%25F0%259F%2591%25BD%25F0%259F%2598%25AB%25F0%259F%2591%25BF%25F0%259F%2598%25BD%25F0%259F%2598%25A9%25F0%259F%2599%2589%26fbclid%3DIwAR0I3l5DBLypLaTjDTCGPQ1i1MmPB2-pE8iqrxrgUK9Kkvq3OX5Mjejibzw&h=AT3nNxW4G-9nAkhXU1EVN-aVGl1o\_-DzDAaWFx9xbprpN3JRBOh17lCQQHNAlIMv6iE4P2vobBAAivLvdzy00K8xqIqb-CvGj6YnnBX6R9wwtj5Y&\_\_tn\_\_=H-y-R&c[0]=AT0eE6OXx\_t9HzpPmMgTdOWAw2ZRNPRDIHJWf699NZYkYzugbWS6g3rOndhPA8fwrCIgk1zn2D1To7phLW9wXkqfgZU1ayT3887\_dxrfN-x822Pos0lCjTIhoQcxfBl516pTz1XrRG\_MbtPpLzUFAGu4nw5W86UR1EkBCZhustNbgTX4wVReiVSuwAWu7Sp1yiWvUm5JXlo76663333hhsgsu](<https://l.facebook.com/l.php?u=https%3A%2F%2Fix.sk%2FNiBZH%3Futm_source%3DYouTube%2520Instagram%26utm_medium%3D2HIqFSGVVB2mFsVTJClrQ7ZnuGJaUt6hu1MNH0vUMjcrgWnUsK%26utm_campaign%3D%25F0%259F%2598%25A9%25F0%259F%2598%258E%25F0%259F%2598%25BF%25F0%259F%25A4%2594%25F0%259F%2598%25A9%25F0%259F%2599%2583%25F0%259F%25A4%25AF%25F0%259F%25A5%25B0%25F0%259F%2598%25AB%25F0%259F%2598%25BA%26utm_id%3D%25F0%259F%2598%258B%25F0%259F%2598%25A5%25F0%259F%25A4%25A1%25F0%259F%2598%25A0%25F0%259F%2598%2587%25F0%259F%25A5%25B4%25F0%259F%25A7%2590%25F0%259F%2598%258E%25F0%259F%2598%2582%25F0%259F%2598%259E%26utm_term%3D%25F0%259F%2598%2584%25F0%259F%25A4%25A9%25F0%259F%2599%2580%25F0%259F%2598%2593%25F0%259F%25A4%25AF%25F0%259F%25A4%25A5%25F0%259F%2591%25BE%25F0%259F%2591%25BF%25F0%259F%2598%25BD%25F0%259F%25A4%25A5%26utm_content%3D%25F0%259F%2591%25BD%25F0%259F%2598%25AB%25F0%259F%2591%25BF%25F0%259F%2598%25BD%25F0%259F%2598%25A9%25F0%259F%2599%2589%26fbclid%3DIwAR0I3l5DBLypLaTjDTCGPQ1i1MmPB2-pE8iqrxrgUK9Kkvq3OX5Mjejibzw&h=AT3nNxW4G-9nAkhXU1EVN-aVGl1o_-DzDAaWFx9xbprpN3JRBOh17lCQQHNAlIMv6iE4P2vobBAAivLvdzy00K8xqIqb-CvGj6YnnBX6R9wwtj5Y&__tn__=H-y-R&c[0]=AT0eE6OXx_t9HzpPmMgTdOWAw2ZRNPRDIHJWf699NZYkYzugbWS6g3rOndhPA8fwrCIgk1zn2D1To7phLW9wXkqfgZU1ayT3887_dxrfN-x822Pos0lCjTIhoQcxfBl516pTz1XrRG_MbtPpLzUFAGu4nw5W86UR1EkBCZhustNbgTX4wVReiVSuwAWu7Sp1yiWvUm5JXlo>)

View File

@@ -1,9 +0,0 @@
---
id: 20250821081408
date: 2025-08-21
keywords:
---
# A test file for Joplin importer
Test

View File

@@ -1,7 +0,0 @@
---
title: test
created: 2025-07-22 17:30:44Z
updated: 2025-07-22 17:37:48Z
---
test

View File

@@ -1,7 +1,7 @@
---
title: "Frontmatter test"
created_at: 01-01-2024 01:23 AM
updated_at: 01-01-2024 04:56 AM
updated_at: 02-01-2024 04:56 AM
---
# Frontmatter test

View File

@@ -165,10 +165,6 @@
if (a && a.toLowerCase().indexOf('math/tex') >= 0) isVisible = true;
}
if (nodeName === 'annotation') {
if (node.getAttribute('encoding') === 'application/x-tex') isVisible = true;
}
if (nodeName === 'source' && nodeParentName === 'picture') {
isVisible = false;
}

View File

@@ -23,7 +23,6 @@ import { defaultWindowId } from '@joplin/lib/reducer';
import { msleep, Second } from '@joplin/utils/time';
import determineBaseAppDirs from '@joplin/lib/determineBaseAppDirs';
import getAppName from '@joplin/lib/getAppName';
import { execCommand } from '@joplin/utils';
interface RendererProcessQuitReply {
canClose: boolean;
@@ -811,33 +810,6 @@ export default class ElectronAppWrapper {
return this.customProtocolHandler_;
}
private async fixLinuxAccessibility_() {
if (this.electronApp().accessibilitySupportEnabled) return;
const isOrcaRunning = async () => {
if (!shim.isLinux()) return false;
try {
const matchingProcesses = await execCommand(['ps', '--no-headers', '-C', 'orca'], { quiet: true });
return matchingProcesses.trim().length > 0;
} catch (error) {
if (error.stderr || error.exitCode !== 1) {
// eslint-disable-next-line no-console -- The main logger is not available at this point.
console.error('Failed to check for and enable accessibility support:', error.stderr);
}
return false;
}
};
// Work around https://issues.chromium.org/issues/431257156 by force-enabling accessibility
// when Orca (a screen reader) is running:
if (await isOrcaRunning()) {
// eslint-disable-next-line no-console -- The main logger is not available at this point.
console.log('Linux accessibility: Enabling full accessibility support.');
this.electronApp().setAccessibilitySupportEnabled(true);
}
}
public async start() {
// Since we are doing other async things before creating the window, we might miss
// the "ready" event. So we use the function below to make sure that the app is ready.
@@ -846,8 +818,6 @@ export default class ElectronAppWrapper {
const alreadyRunning = await this.ensureSingleInstance();
if (alreadyRunning) return;
await this.fixLinuxAccessibility_();
this.customProtocolHandler_ = handleCustomProtocols();
this.createWindow();

View File

@@ -11,7 +11,7 @@ import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
const { friendlySafeFilename } = require('@joplin/lib/path-utils');
import time from '@joplin/lib/time';
import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
import { BrowserWindow } from 'electron';
const md5 = require('md5');
const url = require('url');
@@ -62,10 +62,8 @@ export default class InteropServiceHelper {
htmlFile = await this.exportNoteToHtmlFile(noteId, exportOptions);
const windowOptions: BrowserWindowConstructorOptions = {
// Work around a printing issue: As of Electron 39, if the window is initially hidden, printing crashes the app.
// This only seems to be necessary on Linux.
show: shim.isLinux(),
const windowOptions = {
show: false,
};
win = bridge().newBrowserWindow(windowOptions);
@@ -122,9 +120,6 @@ export default class InteropServiceHelper {
//
// 2025-05-03: Windows and MacOS also need the window.print() workaround.
// See https://github.com/electron/electron/pull/46937.
//
// 2025-10-30: window.print() now causes a crash on Linux -- switch back to the
// other method.
const applyWorkaround = true;
if (applyWorkaround) {

View File

@@ -52,7 +52,7 @@ describe('app.reducer', () => {
...createAppDefaultState({}),
backgroundWindows: {
testWindow: {
...createAppDefaultWindowState(),
...createAppDefaultWindowState(null),
windowId: 'testWindow',
visibleDialogs: {

View File

@@ -30,6 +30,17 @@ export interface NoteIdToScrollPercent {
[noteId: string]: number;
}
type RichTextEditorSelectionBookmark = unknown;
export interface EditorCursorLocations {
readonly richText?: RichTextEditorSelectionBookmark;
readonly markdown?: number;
}
export interface NoteIdToEditorCursorLocations {
[noteId: string]: EditorCursorLocations;
}
export interface VisibleDialogs {
[dialogKey: string]: boolean;
}
@@ -42,6 +53,9 @@ export interface AppWindowState extends WindowState {
devToolsVisible: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
watchedResources: any;
lastEditorScrollPercents: NoteIdToScrollPercent;
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
}
interface BackgroundWindowStates {
@@ -65,7 +79,7 @@ export interface AppState extends State, AppWindowState {
isResettingLayout: boolean;
}
export const createAppDefaultWindowState = (): AppWindowState => {
export const createAppDefaultWindowState = (globalState: AppState|null): AppWindowState => {
return {
...defaultWindowState,
visibleDialogs: {},
@@ -74,6 +88,12 @@ export const createAppDefaultWindowState = (): AppWindowState => {
editorCodeView: true,
devToolsVisible: false,
watchedResources: {},
// Maintain the scroll and cursor location for secondary windows separate from the
// main window. This prevents scrolling in a secondary window from changing/resetting
// the default scroll position in the main window:
lastEditorCursorLocations: globalState?.lastEditorCursorLocations ?? {},
lastEditorScrollPercents: globalState?.lastEditorScrollPercents ?? {},
};
};
@@ -81,7 +101,7 @@ export const createAppDefaultWindowState = (): AppWindowState => {
export function createAppDefaultState(resourceEditWatcherDefaultState: any): AppState {
return {
...defaultState,
...createAppDefaultWindowState(),
...createAppDefaultWindowState(null),
route: {
type: 'NAV_GO',
routeName: 'Main',
@@ -287,6 +307,28 @@ export default function(state: AppState, action: any) {
}
break;
case 'EDITOR_SCROLL_PERCENT_SET':
{
newState = { ...state };
const newPercents = { ...newState.lastEditorScrollPercents };
newPercents[action.noteId] = action.percent;
newState.lastEditorScrollPercents = newPercents;
}
break;
case 'EDITOR_CURSOR_POSITION_SET':
{
newState = { ...state };
const newCursorLocations = { ...newState.lastEditorCursorLocations };
newCursorLocations[action.noteId] = {
...(newCursorLocations[action.noteId] ?? {}),
...action.location,
};
newState.lastEditorCursorLocations = newCursorLocations;
}
break;
case 'NOTE_DEVTOOLS_TOGGLE':
newState = { ...state };
newState.devToolsVisible = !newState.devToolsVisible;

View File

@@ -280,18 +280,6 @@ class Application extends BaseApplication {
Setting.setValue('plugins.states', pluginSettings);
}
// As of Joplin 3.5.7, the ABC rendering is part of the app so we automatically disable the plugin
if (pluginSettings['org.joplinapp.plugins.AbcSheetMusic']) {
pluginSettings = {
...pluginSettings,
['org.joplinapp.plugins.AbcSheetMusic']: {
enabled: false,
deleted: false,
hasBeenUpdated: false,
},
};
}
try {
if (await shim.fsDriver().exists(Setting.value('pluginDir'))) {
await service.loadAndRunPlugins(Setting.value('pluginDir'), pluginSettings);

View File

@@ -1,20 +1,18 @@
import * as convertHtmlToMarkdown from './convertNoteToMarkdown';
import { defaultState, State } from '../reducer';
import Note from '../models/Note';
import { AppState, createAppDefaultState } from '../app.reducer';
import Note from '@joplin/lib/models/Note';
import { MarkupLanguage } from '@joplin/renderer';
import { setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
import Folder from '../models/Folder';
import { NoteEntity } from '../services/database/types';
import shim from '../shim';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import Folder from '@joplin/lib/models/Folder';
import { NoteEntity } from '@joplin/lib/services/database/types';
describe('convertNoteToMarkdown', () => {
let state: State = undefined;
let state: AppState = undefined;
beforeEach(async () => {
state = defaultState;
state = createAppDefaultState({});
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
shim.showToast = jest.fn();
});
it('should set the original note to be trashed', async () => {
@@ -31,6 +29,13 @@ describe('convertNoteToMarkdown', () => {
});
it('should recreate a new note that is a clone of the original', async () => {
let noteConvertedToMarkdownId = '';
const dispatchFn = jest.fn()
.mockImplementationOnce(() => {})
.mockImplementationOnce(action => {
noteConvertedToMarkdownId = action.id;
});
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
@@ -44,11 +49,10 @@ describe('convertNoteToMarkdown', () => {
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
const notes = await Note.previews(folder.id);
expect(notes).toHaveLength(1);
const noteConvertedToMarkdownId = notes[0].id;
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(noteConvertedToMarkdownId).not.toBe('');
const markdownNote = await Note.load(noteConvertedToMarkdownId);
@@ -59,6 +63,15 @@ describe('convertNoteToMarkdown', () => {
});
it('should generate action to trigger notification', async () => {
let originalHtmlNoteId = '';
let actionType = '';
const dispatchFn = jest.fn()
.mockImplementationOnce(action => {
originalHtmlNoteId = action.value;
actionType = action.type;
})
.mockImplementationOnce(() => {});
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
@@ -72,9 +85,12 @@ describe('convertNoteToMarkdown', () => {
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
expect(shim.showToast).toHaveBeenCalled();
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(originalHtmlNoteId).toBe(htmlNote.id);
expect(actionType).toBe('NOTE_HTML_TO_MARKDOWN_DONE');
});
});

View File

@@ -0,0 +1,52 @@
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import { stateUtils } from '@joplin/lib/reducer';
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { MarkupLanguage } from '@joplin/renderer';
import { runtime as convertHtmlToMarkdown } from '@joplin/lib/commands/convertHtmlToMarkdown';
import bridge from '../services/bridge';
export const declaration: CommandDeclaration = {
name: 'convertNoteToMarkdown',
label: () => _('Convert note to Markdown'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);
const note = await Note.load(noteId);
if (!note) return;
try {
const markdownBody = await convertHtmlToMarkdown().execute(context, note.body);
const newNote = await Note.duplicate(note.id);
newNote.body = markdownBody;
newNote.markup_language = MarkupLanguage.Markdown;
await Note.save(newNote);
await Note.delete(note.id, { toTrash: true });
context.dispatch({
type: 'NOTE_HTML_TO_MARKDOWN_DONE',
value: note.id,
});
context.dispatch({
type: 'NOTE_SELECT',
id: newNote.id,
});
} catch (error) {
bridge().showErrorMessageBox(_('Could not convert note to Markdown: %s', error.message));
}
},
enabledCondition: 'oneNoteSelected && noteIsHtml && !noteIsReadOnly',
};
};

View File

@@ -1,4 +1,5 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as convertNoteToMarkdown from './convertNoteToMarkdown';
import * as copyDevCommand from './copyDevCommand';
import * as copyToClipboard from './copyToClipboard';
import * as editProfileConfig from './editProfileConfig';
@@ -13,7 +14,6 @@ import * as openProfileDirectory from './openProfileDirectory';
import * as openSecondaryAppInstance from './openSecondaryAppInstance';
import * as replaceMisspelling from './replaceMisspelling';
import * as restoreNoteRevision from './restoreNoteRevision';
import * as showProfileEditor from './showProfileEditor';
import * as startExternalEditing from './startExternalEditing';
import * as stopExternalEditing from './stopExternalEditing';
import * as switchProfile from './switchProfile';
@@ -25,6 +25,7 @@ import * as toggleSafeMode from './toggleSafeMode';
import * as toggleTabMovesFocus from './toggleTabMovesFocus';
const index: any[] = [
convertNoteToMarkdown,
copyDevCommand,
copyToClipboard,
editProfileConfig,
@@ -39,7 +40,6 @@ const index: any[] = [
openSecondaryAppInstance,
replaceMisspelling,
restoreNoteRevision,
showProfileEditor,
startExternalEditing,
stopExternalEditing,
switchProfile,

View File

@@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import Note from '@joplin/lib/models/Note';
import { createAppDefaultWindowState } from '../app.reducer';
import { AppState, createAppDefaultWindowState } from '../app.reducer';
import Setting from '@joplin/lib/models/Setting';
export const declaration: CommandDeclaration = {
@@ -25,7 +25,7 @@ export const runtime = (): CommandRuntime => {
folderId: note.parent_id,
windowId: `window-${noteId}-${idCounter++}`,
defaultAppWindowState: {
...createAppDefaultWindowState(),
...createAppDefaultWindowState(context.state as AppState),
noteVisiblePanes: Setting.value('noteVisiblePanes'),
editorCodeView: Setting.value('editor.codeView'),
},

View File

@@ -1,20 +0,0 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
name: 'showProfileEditor',
label: () => _('Manage profiles'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
context.dispatch({
type: 'NAV_GO',
routeName: 'ProfileEditor',
});
},
enabledCondition: 'hasMultiProfiles',
};
};

View File

@@ -261,10 +261,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml')) {
const server = settings['sync.11.path'] as string;
const goToSamlLogin = async () => {
// Save settings to allow SAML auth with the correct URL.
await shared.saveSettings(this);
const goToSamlLogin = () => {
this.props.dispatch({
type: 'NAV_GO',
routeName: 'JoplinServerSamlLogin',

View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { useContext, useEffect } from 'react';
import { _ } from '@joplin/lib/locale';
import { Dispatch } from 'redux';
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
import { NotificationType } from '../PopupNotification/types';
interface Props {
noteId: string;
dispatch: Dispatch;
}
export default (props: Props) => {
const popupManager = useContext(PopupNotificationContext);
useEffect(() => {
if (!props.noteId || props.noteId === '') return;
props.dispatch({ type: 'NOTE_HTML_TO_MARKDOWN_DONE', value: '' });
const notification = popupManager.createPopup(() => (
<div>{_('The note has been converted to Markdown and the original note has been moved to the trash')}</div>
), { type: NotificationType.Success });
notification.scheduleDismiss();
}, [props.dispatch, popupManager, props.noteId]);
return <div style={{ display: 'none' }}/>;
};

View File

@@ -106,7 +106,7 @@ const JoplinCloudScreenComponent = (props: Props) => {
<span className={state.className}>{state.errorMessage}</span>
) : null}
</p>
{state.active === 'LINK_USED' ? <div className="loading-animation" /> : null}
{state.active === 'LINK_USED' ? <div id="loading-animation" /> : null}
<JoplinCloudSignUpCallToAction />
</div>
<ButtonBar onCancelClick={() => props.dispatch({ type: 'NAV_BACK' })} />

View File

@@ -38,12 +38,14 @@ import restart from '../services/restart';
import { connect } from 'react-redux';
import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
import validateColumns from './NoteListHeader/utils/validateColumns';
import ConversionNotification from './ConversionNotification/ConversionNotification';
import TrashNotification from './TrashNotification/TrashNotification';
import UpdateNotification from './UpdateNotification/UpdateNotification';
import NoteEditor from './NoteEditor/NoteEditor';
import PluginNotification from './PluginNotification/PluginNotification';
import { Toast } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import { Dispatch } from 'redux';
const ipcRenderer = require('electron').ipcRenderer;
@@ -84,6 +86,7 @@ interface Props {
showInvalidJoplinCloudCredential: boolean;
toast: Toast;
shouldSwitchToAppleSiliconVersion: boolean;
noteHtmlToMarkdownDone: string;
}
interface ShareFolderDialogOptions {
@@ -797,6 +800,10 @@ class MainScreenComponent extends React.Component<Props, State> {
return (
<div style={style}>
<ConversionNotification
noteId={this.props.noteHtmlToMarkdownDone}
dispatch={this.props.dispatch as Dispatch}
/>
<TrashNotification
lastDeletion={this.props.lastDeletion}
lastDeletionNotificationTime={this.props.lastDeletionNotificationTime}
@@ -852,7 +859,8 @@ const mapStateToProps = (state: AppState) => {
notesColumns: validateColumns(state.settings['notes.columns']),
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
toast: state.toast,
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && shim.isMac() && process.arch !== 'arm64',
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && process.arch !== 'arm64',
noteHtmlToMarkdownDone: state.noteHtmlToMarkdownDone,
};
};

View File

@@ -9,6 +9,7 @@ import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import versionInfo, { PackageInfo } from '@joplin/lib/versionInfo';
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
import { ImportModule } from '@joplin/lib/services/interop/Module';
import InteropServiceHelper from '../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
@@ -28,8 +29,6 @@ import { EventName } from '@joplin/lib/eventManager';
import { ipcRenderer } from 'electron';
import NavService from '@joplin/lib/services/NavService';
import Logger from '@joplin/utils/Logger';
import { ImportCommandOptions } from './WindowCommandsAndDialogs/commands/importFrom';
import { FileSystemItem } from '@joplin/lib/services/interop/types';
const logger = Logger.create('MenuBar');
@@ -117,7 +116,7 @@ const useSwitchProfileMenuItems = (profileConfig: ProfileConfig, menuItemDic: an
switchProfileMenuItems.push({ type: 'separator' });
switchProfileMenuItems.push(menuItemDic.addProfile);
switchProfileMenuItems.push(menuItemDic.showProfileEditor);
switchProfileMenuItems.push(menuItemDic.editProfileConfig);
return switchProfileMenuItems;
}, [profileConfig, menuItemDic]);
@@ -305,16 +304,83 @@ function useMenu(props: Props) {
void CommandService.instance().execute(commandName);
}, []);
const onImportModuleClick = useCallback(async (module: ImportModule, moduleSource: FileSystemItem) => {
const options: ImportCommandOptions = {
destinationFolderId: !module.isNoteArchive && moduleSource === 'file' ? props.selectedFolderId : null,
sourcePath: undefined, // Show a file picker
sourceType: moduleSource,
importFormat: module.format,
const onImportModuleClick = useCallback(async (module: ImportModule, moduleSource: string) => {
let path = null;
if (moduleSource === 'file') {
path = await bridge().showOpenDialog({
filters: [{ name: module.description, extensions: module.fileExtensions }],
});
} else {
path = await bridge().showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
});
}
if (!path || (Array.isArray(path) && !path.length)) return;
if (Array.isArray(path)) path = path[0];
const modalMessage = _('Importing from "%s" as "%s" format. Please wait...', path, module.format);
void CommandService.instance().execute('showModalMessage', modalMessage);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const errors: any[] = [];
const importOptions = {
path,
format: module.format,
outputFormat: module.outputFormat,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onProgress: (status: any) => {
const statusStrings: string[] = Object.keys(status).map((key: string) => {
return `${key}: ${status[key]}`;
});
void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onError: (error: any) => {
errors.push(error);
console.warn(error);
},
destinationFolderId: !module.isNoteArchive && moduleSource === 'file' ? props.selectedFolderId : null,
};
await CommandService.instance().execute('importFrom', options);
}, [props.selectedFolderId]);
const service = InteropService.instance();
try {
const result = await service.import(importOptions);
// eslint-disable-next-line no-console
console.info('Import result: ', result);
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
void CommandService.instance().execute('hideModalMessage');
if (errors.length) {
const response = bridge().showErrorMessageBox('There was some errors importing the notes - check the console for more details.\n\nPlease consider sending a bug report to the forum!', {
buttons: [_('Close'), _('Send bug report')],
});
props.dispatch({ type: 'NOTE_DEVTOOLS_SET', value: true });
if (response === 1) {
const url = makeDiscourseDebugUrl(
`Error importing notes from format: ${module.format}`,
`- Input format: ${module.format}\n- Output format: ${module.outputFormat}`,
errors,
packageInfo,
PluginService.instance(),
props.pluginSettings,
);
void bridge().openExternal(url);
}
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.selectedFolderId, props.pluginSettings]);
const onMenuItemClickRef = useRef(null);
onMenuItemClickRef.current = onMenuItemClick;

View File

@@ -22,6 +22,12 @@ interface MultiNoteActionsProps {
function styles_(props: MultiNoteActionsProps) {
return buildStyle('MultiNoteActions', props.themeId, (theme: ThemeStyle) => {
return {
root: {
display: 'inline-flex',
justifyContent: 'center',
paddingTop: theme.marginTop,
width: '100%',
},
itemList: {
display: 'flex',
flexDirection: 'column',
@@ -84,7 +90,7 @@ export default function MultiNoteActions(props: MultiNoteActionsProps) {
}
return (
<div style={styles.root} className='multi-note-actions'>
<div style={styles.root}>
<div style={styles.itemList}>{itemComps}</div>
</div>
);

View File

@@ -31,15 +31,13 @@ function markupToHtml() {
}
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
function countElements(text: string, wordSetter: Function, characterSetter: Function, characterNoSpaceSetter: Function, cjkCharacterSetter: React.Dispatch<React.SetStateAction<number>>, lineSetter: Function) {
function countElements(text: string, wordSetter: Function, characterSetter: Function, characterNoSpaceSetter: Function, lineSetter: Function) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
Countable.count(text, (counter: any) => {
wordSetter(counter.words);
characterSetter(counter.all);
characterNoSpaceSetter(counter.characters);
});
const cjkMatches = text.match(/[\p{Script=Han}\p{Script=Bopomofo}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu);
cjkCharacterSetter(cjkMatches ? cjkMatches.length : 0);
lineSetter(text === '' ? 0 : text.split('\n').length);
}
@@ -60,25 +58,23 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
const [words, setWords] = useState<number>(0);
const [characters, setCharacters] = useState<number>(0);
const [charactersNoSpace, setCharactersNoSpace] = useState<number>(0);
const [cjkCharacters, setCjkCharacters] = useState<number>(0);
// For source with Markdown syntax stripped out
const [strippedLines, setStrippedLines] = useState<number>(0);
const [strippedWords, setStrippedWords] = useState<number>(0);
const [strippedCharacters, setStrippedCharacters] = useState<number>(0);
const [strippedCharactersNoSpace, setStrippedCharactersNoSpace] = useState<number>(0);
const [strippedCjkCharacters, setStrippedCjkCharacters] = useState<number>(0);
const [strippedReadTime, setStrippedReadTime] = useState<number>(0);
// This amount based on the following paper:
// https://www.researchgate.net/publication/332380784_How_many_words_do_we_read_per_minute_A_review_and_meta-analysis_of_reading_rate
const wordsPerMinute = 250;
useEffect(() => {
countElements(props.text, setWords, setCharacters, setCharactersNoSpace, setCjkCharacters, setLines);
countElements(props.text, setWords, setCharacters, setCharactersNoSpace, setLines);
}, [props.text]);
useEffect(() => {
const strippedText: string = markupToHtml().stripMarkup(props.markupLanguage, props.text);
countElements(strippedText, setStrippedWords, setStrippedCharacters, setStrippedCharactersNoSpace, setStrippedCjkCharacters, setStrippedLines);
countElements(strippedText, setStrippedWords, setStrippedCharacters, setStrippedCharactersNoSpace, setStrippedLines);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.text]);
@@ -92,7 +88,6 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
words: words,
characters: characters,
charactersNoSpace: charactersNoSpace,
cjkCharacters: cjkCharacters,
};
const strippedTextProperties: TextPropertiesMap = {
@@ -104,14 +99,12 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
words: strippedWords,
characters: strippedCharacters,
charactersNoSpace: strippedCharactersNoSpace,
cjkCharacters: strippedCjkCharacters,
};
const keyToLabel: KeyToLabelMap = {
words: _('Words'),
characters: _('Characters'),
charactersNoSpace: _('Characters excluding spaces'),
cjkCharacters: _('Chinese/Japanese/Korean characters'),
lines: _('Lines'),
};
@@ -154,7 +147,6 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
);
for (const key in textProperties) {
if (key === 'cjkCharacters' && textProperties[key] === 0 && strippedTextProperties[key] === 0) continue;
const comp = createTableBodyRow(key, textProperties[key], strippedTextProperties[key]);
tableBodyComps.push(comp);
}

View File

@@ -13,7 +13,7 @@ import { _ } from '@joplin/lib/locale';
import bridge from '../../../../../services/bridge';
import shim from '@joplin/lib/shim';
import { MarkupToHtml } from '@joplin/renderer';
import { clipboard } from 'electron';
const { clipboard } = require('electron');
import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../../ErrorBoundary';
import { EditorKeymap, EditorLanguageType, EditorSettings, SearchState, UserEventSource } from '@joplin/editor/types';
@@ -32,7 +32,6 @@ import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
import useSyncEditorValue from './utils/useSyncEditorValue';
import { getGlobalSettings } from '@joplin/renderer/types';
const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);
@@ -94,13 +93,41 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
const editorCutText = useCallback(() => {
if (editorRef.current) {
editorRef.current.cutText(text => clipboard.writeText(text));
const selections = editorRef.current.getSelections();
if (selections.length > 0 && selections[0]) {
clipboard.writeText(selections[0]);
// Easy way to wipe out just the first selection
selections[0] = '';
editorRef.current.replaceSelections(selections);
} else {
const cursor = editorRef.current.getCursor();
const line = editorRef.current.getLine(cursor.line);
clipboard.writeText(`${line}\n`);
const startLine = editorRef.current.getCursor('head');
startLine.ch = 0;
const endLine = {
line: startLine.line + 1,
ch: 0,
};
editorRef.current.replaceRange('', startLine, endLine);
}
}
}, []);
const editorCopyText = useCallback(() => {
if (editorRef.current) {
editorRef.current.copyText(text => clipboard.writeText(text));
const selections = editorRef.current.getSelections();
// Handle the case when there is a selection - copy the selection to the clipboard
// When there is no selection, the selection array contains an empty string.
if (selections.length > 0 && selections[0]) {
clipboard.writeText(selections[0]);
} else {
// This is the case when there is no selection - copy the current line to the clipboard
const cursor = editorRef.current.getCursor();
const line = editorRef.current.getLine(cursor.line);
clipboard.writeText(line);
}
}
}, []);
@@ -221,7 +248,6 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
useCustomPdfViewer: props.useCustomPdfViewer,
noteId: props.noteId,
vendorDir: bridge().vendorDir(),
globalSettings: getGlobalSettings(Setting),
}));
if (cancelled) return;
@@ -366,7 +392,6 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
ignoreModifiers: true,
spellcheckEnabled: Setting.value('editor.spellcheckBeta'),
keymap: keyboardMode,
preferMacShortcuts: shim.isMac(),
indentWithTabs: true,
tabMovesFocus: props.tabMovesFocus,
editorLabel: _('Markdown editor'),

View File

@@ -1051,7 +1051,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor,
});
const noteChangeTimeRef = useRef(Date.now());
const lastNoteIdRef = useRef(props.noteId);
useEffect(() => {
if (!editor) return () => {};
@@ -1069,9 +1068,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// Use nextOnChangeEventInfo's noteId -- lastOnChangeEventInfo can be slightly out-of-date.
const differentNoteId = lastNoteIdRef.current !== props.noteId;
const differentContent = lastOnChangeEventInfo.current.content !== props.content;
if (differentNoteId) noteChangeTimeRef.current = Date.now();
if (differentNoteId || differentContent || !resourcesEqual) {
const result = await props.markupToHtml(
props.contentMarkupLanguage,
@@ -1344,15 +1340,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// keep it this way for now.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function onKeyUp(event: any) {
const timeSinceNoteChange = Date.now() - noteChangeTimeRef.current;
// A key that is pressed before the editor is opened, and that is released after it is
// opened is going to be processed here. For example if the user presses Enter in
// GotoAnything to arrive here. But in that case, we don't want the change handler to be
// activated, because that would change the note timestamp. So we take into account how
// long the note has been loaded before we process the key. Fixes
// https://github.com/laurent22/joplin/issues/12367
if (['Backspace', 'Delete', 'Enter', 'Tab'].includes(event.key) && timeSinceNoteChange > 200) {
if (['Backspace', 'Delete', 'Enter', 'Tab'].includes(event.key)) {
onChangeHandler();
}
}

View File

@@ -5,8 +5,6 @@ import { MarkupToHtmlHandler } from '../../../utils/types';
import { _ } from '@joplin/lib/locale';
import enableTextAreaTab, { TextAreaTabHandler } from './enableTextAreaTab';
import { MarkupToHtml } from '@joplin/renderer';
import { getGlobalSettings } from '@joplin/renderer/types';
import Setting from '@joplin/lib/models/Setting';
interface Props {
editor: Editor;
@@ -92,7 +90,7 @@ function openEditDialog(
onSubmit: async (dialogApi: any) => {
const newSource = newBlockSource(dialogApi.getData().languageInput, dialogApi.getData().codeTextArea, source);
const md = `${newSource.openCharacters}${newSource.content}${newSource.closeCharacters}`;
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true, globalSettings: getGlobalSettings(Setting) });
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true });
// markupToHtml will return the complete editable HTML, but we only
// want to update the inner HTML, so as not to break additional props that

View File

@@ -18,7 +18,7 @@ import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEdi
import CommandService from '@joplin/lib/services/CommandService';
import Button, { ButtonLevel } from '../Button/Button';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import { AppState } from '../../app.reducer';
import { AppState, EditorCursorLocations } from '../../app.reducer';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { _, _n } from '@joplin/lib/locale';
import NoteTitleBar from './NoteTitle/NoteTitleBar';
@@ -50,7 +50,7 @@ import WarningBanner from './WarningBanner/WarningBanner';
import UserWebview from '../../services/plugins/UserWebview';
import Logger from '@joplin/utils/Logger';
import usePluginEditorView from './utils/usePluginEditorView';
import { defaultWindowId, stateUtils } from '@joplin/lib/reducer';
import { stateUtils } from '@joplin/lib/reducer';
import { WindowIdContext } from '../NewWindowOrIFrame';
import useResourceUnwatcher from './utils/useResourceUnwatcher';
import StatusBar from './StatusBar';
@@ -58,7 +58,6 @@ import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisibleP
import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
import getResourceBaseUrl from './utils/getResourceBaseUrl';
import useInitialCursorLocation from './utils/useInitialCursorLocation';
import NotePositionService, { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
const debounce = require('debounce');
@@ -334,6 +333,7 @@ function NoteEditorContent(props: NoteEditorProps) {
const { scrollWhenReadyRef, clearScrollWhenReady } = useScrollWhenReadyOptions({
noteId: formNote.id,
selectedNoteHash: props.selectedNoteHash,
lastEditorScrollPercents: props.lastEditorScrollPercents,
editorRef,
editorName: props.bodyEditor,
});
@@ -401,14 +401,23 @@ function NoteEditorContent(props: NoteEditorProps) {
}, [setShowRevisions]);
const onScroll = useCallback((event: { percent: number }) => {
const noteId = formNoteRef.current.id;
NotePositionService.instance().updateScrollPosition(noteId, windowId, event.percent);
}, [windowId]);
props.dispatch({
type: 'EDITOR_SCROLL_PERCENT_SET',
// In callbacks of setTimeout()/setInterval(), props/state cannot be used
// to refer the current value, since they would be one or more generations old.
// For the purpose, useRef value should be used.
noteId: formNoteRef.current.id,
percent: event.percent,
});
}, [props.dispatch]);
const onCursorMotion = useCallback((location: EditorCursorLocations) => {
const noteId = formNoteRef.current.id;
NotePositionService.instance().updateCursorPosition(noteId, windowId, location);
}, [windowId]);
props.dispatch({
type: 'EDITOR_CURSOR_POSITION_SET',
noteId: formNoteRef.current.id,
location,
});
}, [props.dispatch]);
function renderNoNotes(rootStyle: React.CSSProperties) {
const emptyDivStyle = {
@@ -421,7 +430,7 @@ function NoteEditorContent(props: NoteEditorProps) {
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
const initialCursorLocation = useInitialCursorLocation({
noteId: props.noteId,
lastEditorCursorLocations: props.lastEditorCursorLocations, noteId: props.noteId,
});
const markupLanguage = formNote.markup_language;
@@ -722,8 +731,6 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
bodyEditor = 'CodeMirror5';
}
const mainWindowState = stateUtils.windowStateById(state, defaultWindowId);
return {
noteId,
bodyEditor,
@@ -736,15 +743,15 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
watchedNoteFiles: state.watchedNoteFiles,
notesParentType: windowState.notesParentType,
selectedNoteTags: windowState.selectedNoteTags,
lastEditorScrollPercents: state.lastEditorScrollPercents,
lastEditorCursorLocations: state.lastEditorCursorLocations,
selectedNoteHash: windowState.selectedNoteHash,
searches: state.searches,
selectedSearchId: windowState.selectedSearchId,
customCss: state.customViewerCss,
noteVisiblePanes: windowState.noteVisiblePanes,
watchedResources: windowState.watchedResources,
// For now, only the main window has search UI. Show the same search markers in all
// windows:
highlightedWords: mainWindowState.highlightedWords,
highlightedWords: state.highlightedWords,
plugins: state.pluginService.plugins,
pluginHtmlContents: state.pluginService.pluginHtmlContents,
toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons([

View File

@@ -1,15 +1,7 @@
import { LinkRenderingType } from '@joplin/renderer/MdToHtml';
import { MarkupToHtmlOptions } from './types';
import { getGlobalSettings, ResourceInfos } from '@joplin/renderer/types';
import Setting from '@joplin/lib/models/Setting';
interface OptionOverride {
bodyOnly: boolean;
resourceInfos?: ResourceInfos;
allowedFilePrefixes?: string[];
}
export default (override: OptionOverride = null): MarkupToHtmlOptions => {
export default (override: MarkupToHtmlOptions = null): MarkupToHtmlOptions => {
return {
plugins: {
checkbox: {
@@ -20,7 +12,6 @@ export default (override: OptionOverride = null): MarkupToHtmlOptions => {
},
},
replaceResourceInternalToExternalLinks: true,
globalSettings: getGlobalSettings(Setting),
...override,
};
};

View File

@@ -98,10 +98,6 @@ export async function getResourcesFromPasteEvent(event: any) {
const formatType = format.split('/')[0];
if (formatType === 'image') {
// writeImageToFile can process only image/jpeg, image/jpg or image/png mime types
if (['image/png', 'image/jpg', 'image/jpeg'].indexOf(format) < 0) {
continue;
}
if (event) event.preventDefault();
const image = clipboard.readImage();

View File

@@ -14,7 +14,7 @@ import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
import { RefObject, SetStateAction } from 'react';
import * as React from 'react';
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
import { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
import { EditorCursorLocations, NoteIdToEditorCursorLocations, NoteIdToScrollPercent } from '../../../app.reducer';
export interface AllAssetsOptions {
contentMaxWidthTarget?: string;
@@ -41,6 +41,8 @@ export interface NoteEditorProps {
notesParentType: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
selectedNoteTags: any[];
lastEditorScrollPercents: NoteIdToScrollPercent;
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
selectedNoteHash: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
searches: any[];

View File

@@ -1,17 +1,17 @@
import { useContext, useMemo } from 'react';
import { WindowIdContext } from '../../NewWindowOrIFrame';
import NotePositionService from '@joplin/lib/services/NotePositionService';
import { useMemo } from 'react';
import { EditorCursorLocations, NoteIdToEditorCursorLocations } from '../../../app.reducer';
interface Props {
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
noteId: string;
}
const useInitialCursorLocation = ({ noteId }: Props) => {
const windowId = useContext(WindowIdContext);
const useInitialCursorLocation = ({ noteId, lastEditorCursorLocations }: Props) => {
const lastCursorLocation = lastEditorCursorLocations[noteId];
return useMemo(() => {
return NotePositionService.instance().getCursorPosition(noteId, windowId);
}, [noteId, windowId]);
return useMemo((): EditorCursorLocations => {
return lastCursorLocation ?? { };
}, [lastCursorLocation]);
};
export default useInitialCursorLocation;

View File

@@ -1,43 +1,42 @@
import { RefObject, useCallback, useContext, useRef } from 'react';
import { RefObject, useCallback, useRef } from 'react';
import { NoteBodyEditorRef, ScrollOptions, ScrollOptionTypes } from './types';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import NotePositionService from '@joplin/lib/services/NotePositionService';
import type { NoteIdToScrollPercent } from '../../../app.reducer';
import useNowEffect from '@joplin/lib/hooks/useNowEffect';
import { WindowIdContext } from '../../NewWindowOrIFrame';
interface Props {
noteId: string;
editorName: string;
selectedNoteHash: string;
lastEditorScrollPercents: NoteIdToScrollPercent;
editorRef: RefObject<NoteBodyEditorRef>;
}
const useScrollWhenReadyOptions = ({ noteId, editorName, selectedNoteHash, editorRef }: Props) => {
const useScrollWhenReadyOptions = ({ noteId, editorName, selectedNoteHash, lastEditorScrollPercents, editorRef }: Props) => {
const scrollWhenReadyRef = useRef<ScrollOptions|null>(null);
const previousNoteId = usePrevious(noteId);
const noteIdChanged = noteId !== previousNoteId;
const previousEditor = usePrevious(editorName);
const windowId = useContext(WindowIdContext);
const editorChanged = editorName !== previousEditor;
const lastScrollPercentsRef = useRef<NoteIdToScrollPercent>(null);
lastScrollPercentsRef.current = lastEditorScrollPercents;
// This needs to be a nowEffect to prevent race conditions
useNowEffect(() => {
const editorChanged = editorName !== previousEditor;
const noteIdChanged = noteId !== previousNoteId;
if (!editorChanged && !noteIdChanged) return () => {};
const lastScrollPercent = NotePositionService.instance().getScrollPercent(noteId, windowId) || 0;
scrollWhenReadyRef.current = {
type: selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
value: selectedNoteHash ? selectedNoteHash : lastScrollPercent,
};
if (editorRef.current) {
editorRef.current.resetScroll();
}
const lastScrollPercent = lastScrollPercentsRef.current[noteId] || 0;
scrollWhenReadyRef.current = {
type: selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
value: selectedNoteHash ? selectedNoteHash : lastScrollPercent,
};
return () => {};
}, [editorName, previousEditor, noteId, previousNoteId, selectedNoteHash, editorRef, windowId]);
}, [editorChanged, noteIdChanged, noteId, selectedNoteHash, editorRef]);
const clearScrollWhenReady = useCallback(() => {
scrollWhenReadyRef.current = null;

View File

@@ -25,8 +25,6 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
import { focus } from '@joplin/lib/utils/focusHandler';
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
import { getGlobalSettings } from '@joplin/renderer/types';
import Setting from '@joplin/lib/models/Setting';
interface Props {
themeId: number;
@@ -74,7 +72,6 @@ const useNoteContent = (
const result = await markupToHtml(markupLanguage, noteBody, {
resources: await shared.attachedResources(noteBody),
whiteBackgroundNoteRendering: markupLanguage === MarkupLanguage.Html,
globalSettings: getGlobalSettings(Setting),
});
viewerRef.current.setHtml(result.html, {

View File

@@ -1,8 +1,7 @@
import * as React from 'react';
import { createContext, useEffect, useMemo, useRef, useState } from 'react';
import { createContext, useMemo, useRef, useState } from 'react';
import { NotificationType, PopupHandle, PopupControl as PopupManager } from './types';
import { Hour, msleep } from '@joplin/utils/time';
import shim from '@joplin/lib/shim';
export const PopupNotificationContext = createContext<PopupManager|null>(null);
export const VisibleNotificationsContext = createContext<PopupSpec[]>([]);
@@ -113,18 +112,6 @@ const PopupNotificationProvider: React.FC<Props> = props => {
return manager;
}, []);
useEffect(() => {
const defaultShowToast = shim.showToast;
shim.showToast = async (message: string, options) => {
const popup = popupManager.createPopup(() => message, { type: options?.type ?? NotificationType.Info });
popup.scheduleDismiss();
};
return () => {
shim.showToast = defaultShowToast;
};
}, [popupManager]);
return <PopupNotificationContext.Provider value={popupManager}>
<VisibleNotificationsContext.Provider value={popupSpecs}>
{props.children}

View File

@@ -1,4 +1,3 @@
import { ToastType } from '@joplin/lib/shim';
import * as React from 'react';
export type PopupHandle = {
@@ -6,12 +5,13 @@ export type PopupHandle = {
scheduleDismiss(delay?: number): void;
};
export type NotificationContentCallback = ()=> React.ReactNode;
export enum NotificationType {
Info = 'info',
Success = 'success',
Error = 'error',
}
// NotificationType is an alias for ToastType
export type NotificationType = ToastType;
// eslint-disable-next-line no-redeclare -- export const is necessary for creating an alias, this is not a redeclaration.
export const NotificationType = ToastType;
export type NotificationContentCallback = ()=> React.ReactNode;
export interface PopupOptions {
type?: NotificationType;

View File

@@ -1,47 +0,0 @@
.profile-management {
font-family: var(--joplin-font-family);
display: flex;
flex-direction: column;
> .tableContainer {
overflow-y: scroll;
padding: 20px;
box-sizing: border-box;
flex: 1 1 0%;
color: var(--joplin-color);
> .notification {
margin-bottom: 10px;
}
}
}
.profile-table {
width: 100%;
> thead > tr > .headerCell {
white-space: nowrap;
font-weight: bold;
width: 1px;
}
> tbody > tr {
> .nameCell {
text-overflow: ellipsis;
overflow-x: hidden;
max-width: 1px;
width: 100%;
white-space: nowrap;
}
> .dataCell {
white-space: nowrap;
width: 1px;
color: var(--joplin-color-faded);
}
> .profileActions > button {
margin-right: 10px;
}
}
}

View File

@@ -1,193 +0,0 @@
import * as React from 'react';
import { useState, useEffect, CSSProperties } from 'react';
import ButtonBar from './ConfigScreen/ButtonBar';
import { _ } from '@joplin/lib/locale';
import { connect } from 'react-redux';
import { themeStyle } from '@joplin/lib/theme';
import bridge from '../services/bridge';
import dialogs from './dialogs';
import { Profile, ProfileConfig } from '@joplin/lib/services/profileConfig/types';
import { deleteProfileById, saveProfileConfig } from '@joplin/lib/services/profileConfig';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import { AppState } from '../app.reducer';
import { Dispatch } from 'redux';
const logger = Logger.create('ProfileEditor');
interface Props {
themeId: number;
dispatch: Dispatch;
style: CSSProperties;
profileConfig: ProfileConfig;
}
interface ProfileTableProps {
profiles: Profile[];
currentProfileId: string;
onProfileRename: (profile: Profile)=> void;
onProfileDelete: (profile: Profile)=> void;
themeId: number;
}
const ProfileTableComp: React.FC<ProfileTableProps> = props => {
const theme = themeStyle(props.themeId);
return (
<table className="profile-table">
<thead>
<tr>
<th className="headerCell">{_('Profile name')}</th>
<th className="headerCell">{_('ID')}</th>
<th className="headerCell">{_('Status')}</th>
<th className="headerCell">{_('Actions')}</th>
</tr>
</thead>
<tbody>
{props.profiles.map((profile: Profile, index: number) => {
const isCurrentProfile = profile.id === props.currentProfileId;
return (
<tr key={index}>
<td id={`name-${profile.id}`} className="nameCell">
<span style={{ fontWeight: isCurrentProfile ? 'bold' : 'normal' }}>
{profile.name || `(${_('Untitled')})`}
</span>
</td>
<td className="dataCell">{profile.id}</td>
<td className="dataCell">
{isCurrentProfile ? _('Active') : ''}
</td>
<td className="dataCell profileActions">
<button
id={`rename-${profile.id}`}
aria-labelledby={`rename-${profile.id} name-${profile.id}`}
style={theme.buttonStyle}
onClick={() => props.onProfileRename(profile)}
>
{_('Rename')}
</button>
{!isCurrentProfile && (
<button
id={`delete-${profile.id}`}
aria-labelledby={`delete-${profile.id} name-${profile.id}`}
style={theme.buttonStyle}
onClick={() => props.onProfileDelete(profile)}
>
{_('Delete')}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
);
};
const ProfileEditorComponent: React.FC<Props> = props => {
const { profileConfig, themeId, dispatch } = props;
const theme = themeStyle(themeId);
const style = props.style;
const containerHeight = style.height;
const [profiles, setProfiles] = useState<Profile[]>(profileConfig.profiles);
useEffect(() => {
setProfiles(profileConfig.profiles);
}, [profileConfig]);
const saveNewProfileConfig = async (makeNewProfileConfig: ()=> ProfileConfig) => {
try {
const newProfileConfig = makeNewProfileConfig();
await saveProfileConfig(`${Setting.value('rootProfileDir')}/profiles.json`, newProfileConfig);
dispatch({
type: 'PROFILE_CONFIG_SET',
value: newProfileConfig,
});
} catch (error) {
logger.error(error);
bridge().showErrorMessageBox(error.message);
}
};
const onProfileRename = async (profile: Profile) => {
const newName = await dialogs.prompt(_('Profile name:'), '', profile.name);
if (newName === null || newName === undefined || newName === profile.name) return;
if (!newName.trim()) {
bridge().showErrorMessageBox(_('Profile name cannot be empty'));
return;
}
const makeNewProfileConfig = () => {
const newProfiles = profileConfig.profiles.map(p =>
p.id === profile.id ? { ...p, name: newName.trim() } : p,
);
const newProfileConfig = {
...profileConfig,
profiles: newProfiles,
};
return newProfileConfig;
};
await saveNewProfileConfig(makeNewProfileConfig);
};
const onProfileDelete = async (profile: Profile) => {
const isCurrentProfile = profile.id === profileConfig.currentProfileId;
if (isCurrentProfile) {
bridge().showErrorMessageBox(_('The active profile cannot be deleted. Switch to a different profile and try again.'));
return;
}
const ok = bridge().showConfirmMessageBox(_('Delete profile "%s"?\n\nAll data, including notes, notebooks and tags will be permanently deleted.', profile.name), {
buttons: [_('Delete'), _('Cancel')],
defaultId: 1,
});
if (!ok) return;
const rootDir = Setting.value('rootProfileDir');
const profileDir = `${rootDir}/profile-${profile.id}`;
try {
await shim.fsDriver().remove(profileDir);
logger.info('Deleted profile directory: ', profileDir);
} catch (error) {
logger.error('Error deleting profile directory: ', error);
bridge().showErrorMessageBox(error.message);
}
await saveNewProfileConfig(() => deleteProfileById(profileConfig, profile.id));
};
return (
<div className="profile-management" style={{ ...theme.containerStyle, height: containerHeight }}>
<div className="tableContainer">
<div className="notification" style={theme.notificationBox}>
{_('Manage your profiles. You can rename or delete profiles. The active profile cannot be deleted.')}
</div>
<ProfileTableComp
themeId={themeId}
profiles={profiles}
currentProfileId={profileConfig.currentProfileId}
onProfileRename={onProfileRename}
onProfileDelete={onProfileDelete}
/>
</div>
<ButtonBar
onCancelClick={() => dispatch({ type: 'NAV_BACK' })}
/>
</div>
);
};
const mapStateToProps = (state: AppState) => ({
themeId: state.settings.theme,
profileConfig: state.profileConfig,
});
export default connect(mapStateToProps)(ProfileEditorComponent);

View File

@@ -84,13 +84,7 @@ const ResourceTableComp = (props: ResourceTable) => {
};
const filteredResources = props.resources.filter(
(resource: InnerResource) => {
if (props.filter) {
const filterLowerCase = props.filter.toLowerCase();
return resource.title?.toLowerCase().includes(filterLowerCase) || resource.id.toLowerCase().includes(filterLowerCase);
}
return true;
},
(resource: InnerResource) => !props.filter || resource.title?.includes(props.filter) || resource.id.includes(props.filter),
);
const renderSortableHeader = (title: string, order: SortingOrder) => {
@@ -303,7 +297,7 @@ class ResourceScreenComponent extends React.Component<Props, State> {
<div style={{ ...theme.notificationBox, marginBottom: 10 }}>{
_('This is an advanced tool to show the attachments that are linked to your notes. Please be careful when deleting one of them as they cannot be restored afterwards.')
}</div>
<p style={{ float: 'left', paddingRight: 10 }}>
<div style={{ float: 'right' }}>
<input
style={theme.inputStyle}
type="search"
@@ -311,7 +305,7 @@ class ResourceScreenComponent extends React.Component<Props, State> {
onChange={this.onFilterUpdate}
placeholder={_('Search...')}
/>
</p>
</div>
{this.state.isLoading && <div>{_('Please wait...')}</div>}
{!this.state.isLoading && <div>
{!this.state.resources && <div>

View File

@@ -20,7 +20,6 @@ import Dialog from './Dialog';
import StyleSheetContainer from './StyleSheets/StyleSheetContainer';
import ImportScreen from './ImportScreen';
import ResourceScreen from './ResourceScreen';
import ProfileEditor from './ProfileEditor';
import Navigator from './Navigator';
import WelcomeUtils from '@joplin/lib/WelcomeUtils';
import JoplinCloudLoginScreen from './JoplinCloudLoginScreen';
@@ -166,7 +165,6 @@ class RootComponent extends React.Component<Props, any> {
Import: { screen: ImportScreen, title: () => _('Import') },
Config: { screen: ConfigScreen, title: () => _('Options') },
Resources: { screen: ResourceScreen, title: () => _('Note attachments') },
ProfileEditor: { screen: ProfileEditor, title: () => _('Manage profiles') },
Status: { screen: StatusScreen, title: () => _('Synchronisation Status') },
};

View File

@@ -20,7 +20,6 @@ import { reg } from '@joplin/lib/registry';
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
import { ChangeEvent, Dropdown, DropdownOptions, DropdownVariant } from '../Dropdown/Dropdown';
import shim from '@joplin/lib/shim';
import { SettingsRecord } from '@joplin/lib/models/Setting';
const logger = Logger.create('ShareFolderDialog');
@@ -422,14 +421,10 @@ function ShareFolderDialog(props: Props) {
}
const mapStateToProps = (state: State) => {
const getCanUseSharePermissions = (settings: Partial<SettingsRecord>) => {
return [9, 10, 11].includes(settings['sync.target']) && !!settings['sync.10.canUseSharePermissions'];
};
return {
shares: state.shareService.shares,
shareUsers: state.shareService.shareUsers,
canUseSharePermissions: getCanUseSharePermissions(state.settings),
canUseSharePermissions: state.settings['sync.target'] === 10 && state.settings['sync.10.canUseSharePermissions'],
};
};

View File

@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { _, _n } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import DialogButtonRow from './DialogButtonRow';
import { themeStyle, buildStyle } from '@joplin/lib/theme';
import Dialog from './Dialog';
import DialogTitle from './DialogTitle';
import ShareService from '@joplin/lib/services/share/ShareService';
@@ -28,6 +29,47 @@ interface Props {
syncTargetId: number;
}
function styles_(props: Props) {
return buildStyle('ShareNoteDialog', props.themeId, theme => {
return {
root: {
minWidth: 500,
},
noteList: {
marginBottom: 10,
},
note: {
flex: 1,
flexDirection: 'row',
display: 'flex',
alignItems: 'center',
border: '1px solid',
borderColor: theme.dividerColor,
padding: '0.5em',
marginBottom: 5,
},
noteTitle: {
...theme.textStyle,
flex: 1,
display: 'flex',
color: theme.color,
},
noteRemoveButton: {
background: 'none',
border: 'none',
},
noteRemoveButtonIcon: {
color: theme.color,
fontSize: '1.4em',
},
copyShareLinkButton: {
...theme.buttonStyle,
marginBottom: 10,
},
};
});
}
export function ShareNoteDialog(props: Props) {
const [notes, setNotes] = useState<NoteEntity[]>([]);
const [recursiveShare, setRecursiveShare] = useState<boolean>(false);
@@ -35,6 +77,8 @@ export function ShareNoteDialog(props: Props) {
const syncTargetInfo = useMemo(() => SyncTargetRegistry.infoById(props.syncTargetId), [props.syncTargetId]);
const noteCount = notes.length;
const theme = themeStyle(props.themeId);
const styles = styles_(props);
useEffect(() => {
void ShareService.instance().refreshShares();
@@ -73,8 +117,8 @@ export function ShareNoteDialog(props: Props) {
);
return (
<div key={note.id} className='shared-note-list-item'>
<span className='title'>{note.title}</span>{unshareButton}
<div key={note.id} style={styles.note}>
<span style={styles.noteTitle}>{note.title}</span>{unshareButton}
</div>
);
};
@@ -84,7 +128,7 @@ export function ShareNoteDialog(props: Props) {
for (const note of notes) {
noteComps.push(renderNote(note));
}
return <div className="notes">{noteComps}</div>;
return <div style={styles.noteList}>{noteComps}</div>;
};
const statusMessage = useShareStatusMessage({ sharesState, noteCount });
@@ -92,7 +136,7 @@ export function ShareNoteDialog(props: Props) {
function renderEncryptionWarningMessage() {
if (!encryptionWarning) return null;
return <div className="message">{encryptionWarning}<hr/></div>;
return <div style={theme.textStyle}>{encryptionWarning}<hr/></div>;
}
const onRecursiveShareChange = useCallback(() => {
@@ -111,16 +155,12 @@ export function ShareNoteDialog(props: Props) {
const renderContent = () => {
return (
<div className="form share-note-dialog">
<div style={styles.root} className="form">
<DialogTitle title={_('Publish Notes')}/>
{renderNoteList(notes)}
{renderRecursiveShareCheckbox()}
<button
disabled={[SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(sharesState) >= 0}
className="share"
onClick={shareLinkButton_click}
>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
<div className="message">{statusMessage}</div>
<button disabled={[SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(sharesState) >= 0} style={styles.copyShareLinkButton} onClick={shareLinkButton_click}>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
<div style={theme.textStyle}>{statusMessage}</div>
{renderEncryptionWarningMessage()}
<DialogButtonRow
themeId={props.themeId}

View File

@@ -9,7 +9,7 @@ import { useMemo, useRef, useState } from 'react';
import ItemList from '../ItemList';
import useElementHeight from '../hooks/useElementHeight';
import useSidebarListData from './hooks/useSidebarListData';
import useSelectedSidebarIndexes from './hooks/useSelectedSidebarIndexes';
import useSelectedSidebarIndex from './hooks/useSelectedSidebarIndex';
import useOnSidebarKeyDownHandler from './hooks/useOnSidebarKeyDownHandler';
import useFocusHandler from './hooks/useFocusHandler';
import useOnRenderItem from './hooks/useOnRenderItem';
@@ -26,9 +26,7 @@ interface Props {
tags: TagsWithNoteCountEntity[];
folders: FolderEntity[];
notesParentType: string;
selectedTagIds: string[];
selectedTagId: string;
selectedFolderIds: string[];
selectedFolderId: string;
selectedSmartFilterId: string;
collapsedFolderIds: string[];
@@ -39,7 +37,7 @@ interface Props {
const FolderAndTagList: React.FC<Props> = props => {
const listItems = useSidebarListData(props);
const { selectedIndex, selectedIndexes, updateSelectedIndex } = useSelectedSidebarIndexes({
const { selectedIndex, updateSelectedIndex } = useSelectedSidebarIndex({
...props,
listItems: listItems,
});
@@ -52,7 +50,6 @@ const FolderAndTagList: React.FC<Props> = props => {
const onRenderItem = useOnRenderItem({
...props,
selectedIndex,
selectedIndexes,
listItems,
containerRef: listContainerRef,
});
@@ -61,7 +58,6 @@ const FolderAndTagList: React.FC<Props> = props => {
dispatch: props.dispatch,
listItems: listItems,
selectedIndex,
selectedIndexes,
updateSelectedIndex,
collapsedFolderIds: props.collapsedFolderIds,
});
@@ -111,8 +107,6 @@ const mapStateToProps = (state: AppState) => {
tags: state.tags,
folders: state.folders,
notesParentType: mainWindowState.notesParentType,
selectedFolderIds: mainWindowState.selectedFolderIds,
selectedTagIds: mainWindowState.selectedTagIds,
selectedFolderId: mainWindowState.selectedFolderId,
selectedTagId: mainWindowState.selectedTagId,
collapsedFolderIds: state.collapsedFolderIds,

View File

@@ -1,65 +0,0 @@
import { MouseEvent } from 'react';
import { ModelType } from '@joplin/lib/BaseModel';
import { RefObject, useCallback } from 'react';
import { Dispatch } from 'redux';
import { ListItem, ListItemType } from '../types';
import shim from '@joplin/lib/shim';
export interface ItemClickEvent {
id: string;
type: ModelType;
event: MouseEvent;
}
interface Props {
itemsRef: RefObject<ListItem[]>;
selectedIndexesRef: RefObject<number[]>;
dispatch: Dispatch;
}
const listItemToId = (item: ListItem) => {
if (item.kind === ListItemType.Tag) return item.tag.id;
if (item.kind === ListItemType.Folder) return item.folder.id;
return null;
};
const useOnItemClick = ({ dispatch, selectedIndexesRef, itemsRef }: Props) => {
return useCallback(({ id, type, event }: ItemClickEvent) => {
const action = type === ModelType.Folder ? 'FOLDER_SELECT' : 'TAG_SELECT';
const selectedIndexes = selectedIndexesRef.current;
const findItemIndex = () => itemsRef.current.findIndex(item => listItemToId(item) === id);
if (event.shiftKey && selectedIndexes.length > 0) {
const index = findItemIndex();
if (index === -1) throw new Error(`No item found with ID: ${id}`);
const lastAddedIndex = selectedIndexes[selectedIndexes.length - 1];
const indexStart = Math.min(index, lastAddedIndex);
const indexStop = Math.max(index, lastAddedIndex);
const itemIds = itemsRef.current.slice(indexStart, indexStop + 1)
.map(listItemToId)
.filter(id => !!id);
dispatch({
type: `${action}_ADD`,
ids: itemIds,
});
} else if (shim.isMac() ? event.metaKey : event.ctrlKey) {
const index = findItemIndex();
// Don't allow unselecting all items: Keep at least one item selected
const canDeselect = selectedIndexes.length > 1;
const actionType = canDeselect && selectedIndexes.includes(index) ? 'REMOVE' : 'ADD';
dispatch({
type: `${action}_${actionType}`,
id: id,
});
} else {
dispatch({
type: action,
id: id,
});
}
}, [dispatch, selectedIndexesRef, itemsRef]);
};
export default useOnItemClick;

View File

@@ -1,15 +1,16 @@
import * as React from 'react';
import { DragEventHandler, MouseEventHandler, useCallback, useMemo, useRef } from 'react';
import { ItemClickListener, ItemDragListener, ListItem, ListItemType } from '../types';
import TagItem from '../listItemComponents/TagItem';
import TagItem, { TagLinkClickEvent } from '../listItemComponents/TagItem';
import { Dispatch } from 'redux';
import { clipboard } from 'electron';
import type { MenuItem as MenuItemType } 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';
@@ -17,6 +18,7 @@ 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';
@@ -27,13 +29,12 @@ import Logger from '@joplin/utils/Logger';
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
import HeaderItem from '../listItemComponents/HeaderItem';
import AllNotesItem from '../listItemComponents/AllNotesItem';
import ListItemWrapper, { ItemSelectionState } from '../listItemComponents/ListItemWrapper';
import ListItemWrapper from '../listItemComponents/ListItemWrapper';
import { focus } from '@joplin/lib/utils/focusHandler';
import shim from '@joplin/lib/shim';
import useOnItemClick from './useOnItemClick';
const Menu = bridge().Menu;
const MenuItem: typeof MenuItemType = bridge().MenuItem;
const MenuItem = bridge().MenuItem;
const logger = Logger.create('useOnRenderItem');
@@ -46,7 +47,6 @@ interface Props {
containerRef: React.RefObject<HTMLDivElement>;
selectedIndex: number;
selectedIndexes: number[];
listItems: ListItem[];
}
@@ -65,11 +65,6 @@ const focusListItem = (item: HTMLElement|null) => {
const noFocusListItem = () => {};
const folderCommandToMenuItem = (commandId: string, folderIds: string|string[]) => {
const options = Array.isArray(folderIds) ? { commandFolderIds: folderIds } : { commandFolderId: folderIds };
return new MenuItem(menuUtils.commandToStatefulMenuItem(commandId, folderIds, options));
};
const useOnRenderItem = (props: Props) => {
const pluginsRef = useRef<PluginStates>(null);
@@ -77,6 +72,13 @@ const useOnRenderItem = (props: Props) => {
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;
@@ -92,24 +94,6 @@ const useOnRenderItem = (props: Props) => {
}
}, []);
const selectedIndexesRef = useRef(props.selectedIndexes);
selectedIndexesRef.current = props.selectedIndexes;
const itemsRef = useRef(props.listItems);
itemsRef.current = props.listItems;
const getSelectedIds = useCallback(() => {
return selectedIndexesRef.current.map(index => {
const item = itemsRef.current[index];
if (item.kind === ListItemType.Folder) {
return item.folder.id;
} else if (item.kind === ListItemType.Tag) {
return item.tag.id;
}
return null;
}).filter(id => !!id);
}, []);
const onItemClick = useOnItemClick({ dispatch: props.dispatch, selectedIndexesRef, itemsRef });
const onItemContextMenu: ItemContextMenuListener = useCallback(async event => {
const itemId = event.currentTarget.getAttribute('data-id');
if (itemId === Folder.conflictFolderId()) return;
@@ -117,22 +101,14 @@ const useOnRenderItem = (props: Props) => {
const itemType = Number(event.currentTarget.getAttribute('data-type'));
if (!itemId || !itemType) throw new Error('No data on element');
let itemIds = [itemId];
const itemIndex = Number(event.currentTarget.getAttribute('data-index'));
if (selectedIndexesRef.current.includes(itemIndex)) {
itemIds = getSelectedIds();
}
const state: AppState = store().getState();
let deleteMessage = '';
const deleteButtonLabel = _('Remove');
if (itemType === BaseModel.TYPE_TAG) {
if (itemIds.length === 1) {
const tag = await Tag.load(itemId);
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
} else {
deleteMessage = _('Remove %d tags from all notes? This cannot be undone.', itemIds.length);
}
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?');
}
@@ -155,13 +131,16 @@ const useOnRenderItem = (props: Props) => {
const isDeleted = item ? !!item.deleted_time : false;
if (!isDeleted) {
const isDecryptedFolder = itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied;
if (isDecryptedFolder && itemIds.length === 1) {
menu.append(folderCommandToMenuItem('newFolder', itemId));
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
);
}
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(folderCommandToMenuItem('deleteFolder', itemIds));
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
);
} else {
menu.append(
new MenuItem({
@@ -174,9 +153,7 @@ const useOnRenderItem = (props: Props) => {
if (!ok) return;
if (itemType === BaseModel.TYPE_TAG) {
for (const itemId of itemIds) {
await Tag.untagAll(itemId);
}
await Tag.untagAll(itemId);
} else if (itemType === BaseModel.TYPE_SEARCH) {
props.dispatch({
type: 'SEARCH_DELETE',
@@ -188,18 +165,15 @@ const useOnRenderItem = (props: Props) => {
);
}
if (isDecryptedFolder) {
const whenClause = CommandService.instance().currentWhenClauseContext({ commandFolderIds: itemIds });
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('moveToFolder', itemIds),
// By default, moveToFolder's enabled condition is based on the selected notes. However, the right-click
// menu item applies to folders. For now, use a custom condition:
enabled: !whenClause.foldersIncludeReadOnly,
...menuUtils.commandToStatefulMenuItem('moveToFolder', [itemId]),
// By default, enabled is based on the selected folder. However, the right-click
// menu can be shown for unselected folders.
enabled: true,
}));
}
if (isDecryptedFolder && itemIds.length === 1) {
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId }, { commandFolderId: itemId })));
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
menu.append(new MenuItem({ type: 'separator' }));
@@ -214,17 +188,25 @@ const useOnRenderItem = (props: Props) => {
new MenuItem({
label: module.fullLabel(),
click: async () => {
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: itemIds, plugins: pluginsRef.current });
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
},
}),
);
}
// Only show the share/leave share actions for top-level folders
const shareFolderItem = folderCommandToMenuItem('showShareFolderDialog', itemId);
if (shareFolderItem.enabled) menu.append(shareFolderItem);
const leaveSharedFolderItem = folderCommandToMenuItem('leaveSharedFolder', itemId);
if (leaveSharedFolderItem.enabled) menu.append(leaveSharedFolderItem);
// 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({
@@ -234,14 +216,14 @@ const useOnRenderItem = (props: Props) => {
);
if (Setting.value('notes.perFolderSortOrderEnabled')) {
menu.append(new MenuItem({
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId, { commandFolderId: itemId }),
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId),
type: 'checkbox',
checked: PerFolderSortOrderService.isSet(itemId),
}));
}
}
if (itemType === BaseModel.TYPE_FOLDER && itemIds.length === 1) {
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(
new MenuItem({
label: _('Copy external link'),
@@ -252,7 +234,7 @@ const useOnRenderItem = (props: Props) => {
);
}
if (itemType === BaseModel.TYPE_TAG && itemIds.length === 1) {
if (itemType === BaseModel.TYPE_TAG) {
menu.append(new MenuItem(
menuUtils.commandToStatefulMenuItem('renameTag', itemId),
));
@@ -271,22 +253,24 @@ const useOnRenderItem = (props: Props) => {
for (const view of pluginViews) {
const location = view.location;
if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu) {
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 === ModelType.Folder && location === MenuItemLocation.FolderContextMenu) {
menu.append(folderCommandToMenuItem(view.commandName, itemId));
}
}
} else {
if (itemType === BaseModel.TYPE_FOLDER) {
menu.append(folderCommandToMenuItem('restoreFolder', itemIds));
menu.append(
new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', itemId)),
);
}
}
menu.popup({ window: bridge().activeWindow() });
}, [props.dispatch, pluginsRef, getSelectedIds]);
}, [props.dispatch, pluginsRef]);
@@ -294,16 +278,10 @@ const useOnRenderItem = (props: Props) => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
if (!folderId) return;
let itemIds = [folderId];
const itemIndex = Number(event.currentTarget.getAttribute('data-index'));
if (selectedIndexesRef.current.includes(itemIndex)) {
itemIds = getSelectedIds();
}
event.dataTransfer.setDragImage(new Image(), 1, 1);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify(itemIds));
}, [getSelectedIds]);
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();
@@ -345,6 +323,13 @@ const useOnRenderItem = (props: Props) => {
});
}, [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
@@ -353,26 +338,22 @@ const useOnRenderItem = (props: Props) => {
return Folder.shouldShowFolderIcons(props.folders);
}, [props.folders]);
const selectedIndexRef = useRef(props.selectedIndex);
selectedIndexRef.current = props.selectedIndex;
const itemCount = props.listItems.length;
return useCallback((item: ListItem, index: number) => {
const primarySelected = props.selectedIndex === index;
const selected = primarySelected || props.selectedIndexes.includes(index);
const selectionState: ItemSelectionState = {
primarySelected,
selected,
multipleItemsSelected: props.selectedIndexes.length > 1,
};
const selected = props.selectedIndex === index;
const focusInList = document.hasFocus() && props.containerRef.current?.contains(document.activeElement);
const anchorRef = (focusInList && primarySelected) ? focusListItem : noFocusListItem;
const anchorRef = (focusInList && selected) ? focusListItem : noFocusListItem;
if (item.kind === ListItemType.Tag) {
const tag = item.tag;
return <TagItem
key={item.key}
anchorRef={anchorRef}
selectionState={selectionState}
onClick={onItemClick}
selected={selected}
onClick={tagItem_click}
onTagDrop={onTagDrop_}
onContextMenu={onItemContextMenu}
label={item.label}
@@ -402,7 +383,7 @@ const useOnRenderItem = (props: Props) => {
return <FolderItem
key={item.key}
anchorRef={anchorRef}
selectionState={selectionState}
selected={selected}
folderId={folder.id}
folderTitle={item.label}
folderIcon={Folder.unserializeIcon(folder.icon)}
@@ -414,7 +395,7 @@ const useOnRenderItem = (props: Props) => {
onFolderDragOver_={onFolderDragOver_}
onFolderDrop_={onFolderDrop_}
itemContextMenu={onItemContextMenu}
folderItem_click={onItemClick}
folderItem_click={folderItem_click}
onFolderToggleClick_={onFolderToggleClick_}
shareId={folder.share_id}
parentId={folder.parent_id}
@@ -427,7 +408,7 @@ const useOnRenderItem = (props: Props) => {
key={item.id}
anchorRef={anchorRef}
item={item}
selectionState={selectionState}
isSelected={selected}
onDrop={item.supportsFolderDrop ? onFolderDrop_ : null}
index={index}
itemCount={itemCount}
@@ -436,7 +417,7 @@ const useOnRenderItem = (props: Props) => {
return <AllNotesItem
key={item.key}
anchorRef={anchorRef}
selectionState={selectionState}
selected={selected}
item={item}
index={index}
itemCount={itemCount}
@@ -447,7 +428,7 @@ const useOnRenderItem = (props: Props) => {
key={item.key}
containerRef={anchorRef}
depth={1}
selectionState={selectionState}
selected={selected}
itemIndex={index}
itemCount={itemCount}
highlightOnHover={false}
@@ -461,7 +442,7 @@ const useOnRenderItem = (props: Props) => {
return exhaustivenessCheck;
}
}, [
onItemClick,
folderItem_click,
onFolderDragOver_,
onFolderDragStart_,
onFolderDrop_,
@@ -471,8 +452,8 @@ const useOnRenderItem = (props: Props) => {
props.collapsedFolderIds,
props.folders,
showFolderIcons,
tagItem_click,
props.selectedIndex,
props.selectedIndexes,
props.containerRef,
itemCount,
]);

View File

@@ -9,7 +9,6 @@ interface Props {
listItems: ListItem[];
collapsedFolderIds: string[];
selectedIndex: number;
selectedIndexes: number[];
updateSelectedIndex: SetSelectedIndexCallback;
}
@@ -69,7 +68,7 @@ const findNextTypeAheadMatch = (selectedIndex: number, query: string, listItems:
};
const useOnSidebarKeyDownHandler = (props: Props) => {
const { updateSelectedIndex, listItems, selectedIndex, selectedIndexes, collapsedFolderIds, dispatch } = props;
const { updateSelectedIndex, listItems, selectedIndex, collapsedFolderIds, dispatch } = props;
return useCallback<KeyboardEventHandler<HTMLElement>>((event) => {
const selectedItem = listItems[selectedIndex];
@@ -105,15 +104,12 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
event.preventDefault();
} else if (event.code === 'Home') {
event.preventDefault();
updateSelectedIndex(0, { extend: false });
updateSelectedIndex(0);
indexChange = 0;
} else if (event.code === 'End') {
event.preventDefault();
updateSelectedIndex(listItems.length - 1, { extend: false });
updateSelectedIndex(listItems.length - 1);
indexChange = 0;
} else if (event.code === 'Escape' && selectedIndexes.length > 1) {
event.preventDefault();
updateSelectedIndex(selectedIndex, { extend: false });
} else if (event.code === 'Enter' && !event.shiftKey) {
event.preventDefault();
void CommandService.instance().execute('focusElement', 'noteList');
@@ -126,9 +122,9 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
if (indexChange !== 0) {
event.preventDefault();
updateSelectedIndex(selectedIndex + indexChange, { extend: event.shiftKey });
updateSelectedIndex(selectedIndex + indexChange);
}
}, [selectedIndex, selectedIndexes, collapsedFolderIds, listItems, updateSelectedIndex, dispatch]);
}, [selectedIndex, collapsedFolderIds, 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

@@ -1,132 +0,0 @@
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');
type UpdateSelectedIndexOptions = { extend: boolean };
interface Props {
dispatch: Dispatch;
listItems: ListItem[];
notesParentType: string;
selectedTagId: string;
selectedTagIds: string[];
selectedFolderId: string;
selectedFolderIds: string[];
selectedSmartFilterId: string;
}
const useSelectedSidebarIndexes = (props: Props) => {
const isIndexInSelection = useCallback((index: number) => {
const listItem = props.listItems[index];
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, {
selectedFolderIds: props.selectedFolderIds,
notesParentType: props.notesParentType,
});
} else if (listItem.kind === ListItemType.Tag) {
selected = isTagSelected(listItem.tag, { selectedTagIds: props.selectedTagIds, notesParentType: props.notesParentType });
} else {
const exhaustivenessCheck: never = listItem;
return exhaustivenessCheck;
}
return selected;
}, [props.listItems, props.selectedFolderIds, props.selectedTagIds, props.selectedSmartFilterId, props.notesParentType]);
const isIndexPrimarySelected = useCallback((index: number) => {
const listItem = props.listItems[index];
if (listItem.kind === ListItemType.Folder) {
return isFolderSelected(listItem.folder, {
selectedFolderIds: [props.selectedFolderId],
notesParentType: props.notesParentType,
});
} else if (listItem.kind === ListItemType.Tag) {
return isTagSelected(listItem.tag, { selectedTagIds: [props.selectedTagId], notesParentType: props.notesParentType });
} else {
return isIndexInSelection(index);
}
}, [props.listItems, isIndexInSelection, props.selectedFolderId, props.selectedTagId, props.notesParentType]);
const appStateSelectedIndexes = useMemo(() => {
const selectedIndexes = [];
for (let i = 0; i < props.listItems.length; i++) {
if (isIndexInSelection(i)) {
selectedIndexes.push(i);
}
}
return selectedIndexes;
}, [props.listItems, isIndexInSelection]);
const appStateSelectedIndex = useMemo(() => {
return props.listItems.findIndex((_item, index) => isIndexPrimarySelected(index));
}, [props.listItems, isIndexPrimarySelected]);
// The main index of all selected indexes. This is where the focus will go.
// Ignored if not included in appStateSelectedIndexes.
const [primarySelectedIndex, setPrimarySelectedIndex] = useState(0);
// 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);
setPrimarySelectedIndex(appStateSelectedIndex);
}, [appStateSelectedIndex]);
const updateSelectedIndex = useCallback((newIndex: number, options: UpdateSelectedIndexOptions) => {
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: options.extend ? 'FOLDER_SELECT_ADD' : 'FOLDER_SELECT',
id: newItem.folder.id,
});
} else if (newItem.kind === ListItemType.Tag) {
props.dispatch({
type: options.extend ? 'TAG_SELECT_ADD' : 'TAG_SELECT',
id: newItem.tag.id,
});
} else {
newOverrideIndex = newIndex;
}
setSelectedIndexOverride(newOverrideIndex);
setPrimarySelectedIndex(newIndex);
}, [props.listItems, props.dispatch]);
const selectedIndexes = useMemo(() => {
return selectedIndexOverride === -1 ? appStateSelectedIndexes : [selectedIndexOverride];
}, [appStateSelectedIndexes, selectedIndexOverride]);
const selectedIndex = selectedIndexes.includes(primarySelectedIndex) ? primarySelectedIndex : (selectedIndexes[0] ?? -1);
return {
selectedIndex,
selectedIndexes,
updateSelectedIndex,
};
};
export default useSelectedSidebarIndexes;

View File

@@ -9,7 +9,7 @@ import CommandService from '@joplin/lib/services/CommandService';
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
import { connect } from 'react-redux';
import EmptyExpandLink from './EmptyExpandLink';
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
import { ListItem } from '../types';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
@@ -19,7 +19,7 @@ const MenuItem = bridge().MenuItem;
interface Props {
dispatch: Dispatch;
anchorRef: ListItemRef;
selectionState: ItemSelectionState;
selected: boolean;
item: ListItem;
index: number;
itemCount: number;
@@ -53,7 +53,7 @@ const AllNotesItem: React.FC<Props> = props => {
<ListItemWrapper
containerRef={props.anchorRef}
key="allNotesHeader"
selectionState={props.selectionState}
selected={props.selected}
depth={props.item.depth}
className={'list-item-container list-item-depth-0 all-notes'}
highlightOnHover={true}
@@ -65,7 +65,7 @@ const AllNotesItem: React.FC<Props> = props => {
<StyledListItemAnchor
className="list-item"
isSpecialItem={true}
selected={props.selectionState.selected}
selected={props.selected}
onClick={onAllNotesClick_}
onContextMenu={toggleAllNotesContextMenu}
>

View File

@@ -10,9 +10,8 @@ import Folder from '@joplin/lib/models/Folder';
import { ModelType } from '@joplin/lib/BaseModel';
import { _ } from '@joplin/lib/locale';
import NoteCount from './NoteCount';
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
import { useId } from 'react';
import { ItemClickEvent } from '../hooks/useOnItemClick';
const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) {
@@ -43,17 +42,17 @@ interface FolderItemProps {
onFolderDragOver_: ItemDragListener;
onFolderDrop_: ItemDragListener;
itemContextMenu: ItemContextMenuListener;
folderItem_click: (event: ItemClickEvent)=> void;
folderItem_click: (folderId: string)=> void;
onFolderToggleClick_: ItemClickListener;
shareId: string;
selectionState: ItemSelectionState;
selected: boolean;
index: number;
itemCount: number;
}
function FolderItem(props: FolderItemProps) {
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selectionState, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const shareTitle = _('Shared');
const shareIcon = shareId && !parentId ? <StyledShareIcon aria-label={shareTitle} title={shareTitle} className="fas fa-share-alt"/> : null;
@@ -74,11 +73,11 @@ function FolderItem(props: FolderItemProps) {
containerRef={props.anchorRef}
// Folders are contained within the "Notebooks" section (which has depth 0):
depth={depth + 1}
selectionState={selectionState}
selected={selected}
itemIndex={props.index}
itemCount={props.itemCount}
expanded={hasChildren ? props.isExpanded : undefined}
className={`list-item-container list-item-depth-${depth} ${selectionState.selected ? 'selected' : ''}`}
className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`}
highlightOnHover={true}
onDragStart={onFolderDragStart_}
onDragOver={onFolderDragOver_}
@@ -96,15 +95,13 @@ function FolderItem(props: FolderItemProps) {
className="list-item"
id={titleId}
isConflictFolder={folderId === Folder.conflictFolderId()}
selected={selectionState.selected}
selected={selected}
shareId={shareId}
data-folder-id={folderId}
onDoubleClick={onFolderToggleClick_}
onClick={(event: React.MouseEvent) => {
folderItem_click({
id: folderId, type: ModelType.Folder, event,
});
onClick={() => {
folderItem_click(folderId);
}}
>
{doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix>

View File

@@ -5,7 +5,7 @@ import { HeaderId, HeaderListItem } from '../types';
import bridge from '../../../services/bridge';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
@@ -15,7 +15,7 @@ const menuUtils = new MenuUtils(CommandService.instance());
interface Props {
anchorRef: ListItemRef;
item: HeaderListItem;
selectionState: ItemSelectionState;
isSelected: boolean;
onDrop: React.DragEventHandler|null;
index: number;
itemCount: number;
@@ -47,7 +47,7 @@ const HeaderItem: React.FC<Props> = props => {
return (
<ListItemWrapper
containerRef={props.anchorRef}
selectionState={props.selectionState}
selected={props.isSelected}
itemIndex={props.index}
itemCount={props.itemCount}
expanded={props.item.expanded}

View File

@@ -4,18 +4,9 @@ import { useMemo } from 'react';
export type ListItemRef = React.Ref<HTMLDivElement>;
export interface ItemSelectionState {
selected: boolean;
// The item with primary selection is used for actions that support only one folder.
// Only one item can have primary selection.
primarySelected: boolean;
multipleItemsSelected: boolean;
}
interface Props {
containerRef: ListItemRef;
selectionState: ItemSelectionState;
selected: boolean;
itemIndex: number;
itemCount: number;
expanded?: boolean|undefined;
@@ -44,17 +35,15 @@ const ListItemWrapper: React.FC<Props> = props => {
} as React.CSSProperties;
}, [props.depth]);
const { selected, primarySelected, multipleItemsSelected } = props.selectionState;
return (
<div
ref={props.containerRef}
aria-posinset={props.itemIndex + 1}
aria-setsize={props.itemCount}
aria-selected={selected}
aria-selected={props.selected}
aria-expanded={props.expanded}
aria-level={props.depth}
tabIndex={primarySelected ? 0 : -1}
tabIndex={props.selected ? 0 : -1}
onContextMenu={props.onContextMenu}
onDrag={props.onDrag}
@@ -64,17 +53,10 @@ const ListItemWrapper: React.FC<Props> = props => {
draggable={props.draggable}
role='treeitem'
className={[
'list-item-wrapper',
props.highlightOnHover ? '-highlight-on-hover' : '',
selected ? '-selected' : '',
primarySelected && multipleItemsSelected ? '-selected-primary' : '',
props.className ?? '',
].join(' ')}
className={`list-item-wrapper ${props.highlightOnHover ? '-highlight-on-hover' : ''} ${props.selected ? '-selected' : ''} ${props.className ?? ''}`}
style={style}
data-folder-id={props['data-folder-id']}
data-id={props['data-id']}
data-index={props.itemIndex}
data-tag-id={props['data-tag-id']}
data-type={props['data-type']}
aria-labelledby={props['aria-labelledby']}

View File

@@ -3,27 +3,28 @@ import * as React from 'react';
import { useCallback } from 'react';
import { StyledListItemAnchor, StyledSpanFix } from '../styles';
import { TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import BaseModel from '@joplin/lib/BaseModel';
import NoteCount from './NoteCount';
import EmptyExpandLink from './EmptyExpandLink';
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
import { ItemClickEvent } from '../hooks/useOnItemClick';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
export type TagLinkClickEvent = { tag: TagsWithNoteCountEntity|undefined };
interface Props {
anchorRef: ListItemRef;
selectionState: ItemSelectionState;
selected: boolean;
tag: TagsWithNoteCountEntity;
label: string;
onTagDrop: React.DragEventHandler<HTMLElement>;
onContextMenu: React.MouseEventHandler<HTMLElement>;
onClick: (event: ItemClickEvent)=> void;
onClick: (event: TagLinkClickEvent)=> void;
itemCount: number;
index: number;
}
const TagItem = (props: Props) => {
const { tag, selectionState } = props;
const { tag, selected } = props;
let noteCount = null;
if (Setting.value('showNoteCounts')) {
@@ -31,31 +32,30 @@ const TagItem = (props: Props) => {
noteCount = <NoteCount count={count}/>;
}
const onClickHandler: React.MouseEventHandler<HTMLElement> = useCallback((event) => {
props.onClick({ id: tag.id, type: ModelType.Tag, event });
const onClickHandler = useCallback(() => {
props.onClick({ tag });
}, [props.onClick, tag]);
return (
<ListItemWrapper
containerRef={props.anchorRef}
selectionState={selectionState}
selected={selected}
depth={1}
className={`list-item-container ${selectionState.selected ? 'selected' : ''}`}
className={`list-item-container ${selected ? 'selected' : ''}`}
highlightOnHover={true}
onDrop={props.onTagDrop}
onContextMenu={props.onContextMenu}
data-id={tag.id}
data-tag-id={tag.id}
data-type={ModelType.Tag}
aria-selected={selected}
itemIndex={props.index}
itemCount={props.itemCount}
>
<EmptyExpandLink/>
<StyledListItemAnchor
className="list-item"
selected={selectionState.selected}
selected={selected}
data-id={tag.id}
data-type={BaseModel.TYPE_TAG}
onContextMenu={props.onContextMenu}
onClick={onClickHandler}
>
<StyledSpanFix className="tag-label">{props.label}</StyledSpanFix>

View File

@@ -22,30 +22,7 @@
background: var(--joplin-selected-color2);
}
// When multiple items are selected, show an outline (similar to the focus outline) to indicate
// which folder has the primary selection.
&.-selected-primary {
--outline-color: var(--joplin-focus-outline-color-dimmed);
outline: 1px solid var(--outline-color);
// Also adjust the background color: This makes it clearer which item has primary focus,
// especially when using a dimmed outline.
background-color: color-mix(
in srgb,
var(--outline-color) 12%,
var(--joplin-selected-color2) 92%
);
// For accessibility, use a different style when actually focused. This makes it easier to
// tell where the keyboard focus is.
&:focus {
--outline-color: var(--joplin-focus-outline-color);
}
}
// Don't highlight selected items on hover -- doing so makes it
// difficult to tell whether the hovered item is selected or not.
&.-highlight-on-hover:not(.-selected):hover {
&.-highlight-on-hover:hover {
background-color: var(--joplin-background-color-hover2);
}
}

View File

@@ -57,10 +57,8 @@ export interface SpacerListItem extends ToplevelListItem {
export type ListItem = HeaderListItem|AllNotesListItem|TagListItem|FolderListItem|SpacerListItem;
interface SetSelectedIndexOptions {
extend: boolean;
}
export type SetSelectedIndexCallback = (newIndex: number, options: SetSelectedIndexOptions)=> void;
export type SetSelectedIndexCallback = (newIndex: number)=> void;
export type ItemDragListener = DragEventHandler<HTMLElement>;

View File

@@ -40,18 +40,12 @@ async function exportDebugReportClick() {
}
function StatusScreen(props: Props) {
const [loading, setLoading] = useState(false);
const [report, setReport] = useState<ReportSection[]>([]);
async function refreshScreen() {
setLoading(true);
try {
const service = new ReportService();
const r = await service.status(Setting.value('sync.target'));
setReport(r);
} finally {
setLoading(false);
}
const service = new ReportService();
const r = await service.status(Setting.value('sync.target'));
setReport(r);
}
useEffect(() => {
@@ -214,7 +208,6 @@ function StatusScreen(props: Props) {
<div style={style}>
<div style={containerStyle}>
{renderTools()}
{loading && <p><span className='loading-animation'/> {_('Loading...')}</p>}
{body}
</div>
<ButtonBar

View File

@@ -14,7 +14,7 @@ const ModalMessageOverlay: React.FC<Props> = ({ message }) => {
return <Dialog contentFillsScreen={true}>
<div className="modal-message">
<div className="loading-animation" />
<div id="loading-animation" />
<div className="text" role="status">
{lines}
</div>

View File

@@ -2,7 +2,6 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale';
import bridge from '../../../services/bridge';
import Folder from '@joplin/lib/models/Folder';
import { getTrashFolderId } from '@joplin/lib/services/trash';
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
export const declaration: CommandDeclaration = {
@@ -12,37 +11,22 @@ export const declaration: CommandDeclaration = {
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, folderIds: string|string[] = null) => {
if (folderIds === null) {
folderIds = context.state.selectedFolderIds;
}
if (!Array.isArray(folderIds)) {
folderIds = [folderIds];
execute: async (context: CommandContext, folderId: string = null) => {
if (folderId === null) folderId = context.state.selectedFolderId;
const folder = await Folder.load(folderId);
if (!folder) throw new Error(`No such folder: ${folderId}`);
let deleteMessage = _('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folder.title, 0, 32));
if (folderId === context.state.settings['sync.10.inboxId']) {
deleteMessage = _('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.');
}
folderIds = folderIds.filter(id => id !== getTrashFolderId());
if (folderIds.length === 0) {
throw new Error('Nothing to do: At least one valid folder must be specified.');
}
const folders = await Folder.loadItemsByIdsOrFail(folderIds);
const deleteMessage = [];
if (folders.length === 1) {
deleteMessage.push(_('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folders[0].title, 0, 32)));
} else {
deleteMessage.push(_('Move %d notebooks to the trash?\n\nAll notes and sub-notebooks within these notebooks will also be moved to the trash.', folders.length));
}
if (folders.some(folder => folder.id === context.state.settings['sync.10.inboxId'])) {
deleteMessage.push(_('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.'));
}
const ok = bridge().showConfirmMessageBox(deleteMessage.join('\n\n'));
const ok = bridge().showConfirmMessageBox(deleteMessage);
if (!ok) return;
await Folder.batchDelete(folderIds, { toTrash: true, sourceDescription: 'deleteFolder command' });
await Folder.delete(folderId, { toTrash: true, sourceDescription: 'deleteFolder command' });
},
enabledCondition: '!foldersIncludeReadOnly',
enabledCondition: '!folderIsReadOnly',
};
};

View File

@@ -1,170 +0,0 @@
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import InteropService from '@joplin/lib/services/interop/InteropService';
import { FileSystemItem, ImportModuleOutputFormat, ModuleType } from '@joplin/lib/services/interop/types';
import bridge from '../../../services/bridge';
import { WindowControl } from '../utils/useWindowControl';
import { _ } from '@joplin/lib/locale';
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import Setting from '@joplin/lib/models/Setting';
import { PackageInfo } from '@joplin/lib/versionInfo';
import shim from '@joplin/lib/shim';
import { ImportModule } from '@joplin/lib/services/interop/Module';
import Logger from '@joplin/utils/Logger';
const packageInfo: PackageInfo = require('../../../packageInfo.js');
const logger = Logger.create('importFrom');
export const declaration: CommandDeclaration = {
name: 'importFrom',
label: () => _('Import...'),
};
export interface ImportCommandOptions {
sourcePath: string|undefined;
sourceType: FileSystemItem;
destinationFolderId: string|null;
importFormat: string;
outputFormat: ImportModuleOutputFormat;
}
const findImportModule = async (commandOptions: ImportCommandOptions|null, control: WindowControl) => {
if (commandOptions) {
const module = InteropService.instance().findModuleByFormat(
ModuleType.Importer, commandOptions.importFormat, commandOptions.sourceType, commandOptions.outputFormat);
if (module) {
return module as ImportModule;
}
}
const importModules = InteropService.instance().modules().filter(module => module.type === ModuleType.Importer) as ImportModule[];
return await control.showPrompt({
label: _('Select the type of file to be imported:'),
value: '',
suggestions: importModules.map(module => {
const label = module.fullLabel();
return {
key: `${module.type}--${label}`,
value: module,
label: module.fullLabel(),
};
}),
});
};
const promptForSourcePath = async (module: ImportModule, sourceType: FileSystemItem|undefined) => {
if (!sourceType) {
if (!module.sources.includes(FileSystemItem.Directory)) {
sourceType = FileSystemItem.File;
}
if (!module.sources.includes(FileSystemItem.File)) {
sourceType = FileSystemItem.Directory;
}
}
if (sourceType === FileSystemItem.File) {
return await bridge().showOpenDialog({
filters: [{ name: module.description, extensions: module.fileExtensions }],
});
} else if (sourceType === FileSystemItem.Directory) {
return await bridge().showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
});
} else {
return await bridge().showOpenDialog({
properties: ['openDirectory', 'openFile'],
});
}
};
export const runtime = (control: WindowControl): CommandRuntime => {
return {
// Since this can be run from "go to anything", partialOptions needs to support being null or empty.
execute: async (context: CommandContext, options: ImportCommandOptions|undefined) => {
const importModule = await findImportModule(options, control);
if (!importModule) return null; // E.g. if cancelled
let sourcePath = options?.sourcePath ?? await promptForSourcePath(importModule, options?.sourceType);
if (Array.isArray(sourcePath)) {
sourcePath = sourcePath[0];
}
// Handle the case where the directory picker action was cancelled
if (!sourcePath) return null;
if (!options) {
const isDirectory = await shim.fsDriver().isDirectory(sourcePath);
const importsMultipleNotes = importModule.isNoteArchive || isDirectory;
const destinationFolderId = importsMultipleNotes ? null : context.state.selectedFolderId;
const importFormat = importModule.format;
const outputFormat = importModule.outputFormat;
options = {
sourcePath,
destinationFolderId,
importFormat,
outputFormat,
sourceType: isDirectory ? FileSystemItem.Directory : FileSystemItem.File,
};
}
const modalMessage = _('Importing from "%s" as "%s" format. Please wait...', sourcePath, options.importFormat);
void CommandService.instance().execute('showModalMessage', modalMessage);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const errors: any[] = [];
const importOptions = {
path: sourcePath,
format: options.importFormat,
outputFormat: options.outputFormat,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onProgress: (status: any) => {
const statusStrings: string[] = Object.keys(status).map((key: string) => {
return `${key}: ${status[key]}`;
});
void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onError: (error: any) => {
errors.push(error);
console.warn(error);
},
destinationFolderId: options.destinationFolderId,
};
const service = InteropService.instance();
try {
const result = await service.import(importOptions);
// eslint-disable-next-line no-console
console.info('Import result: ', result);
} catch (error) {
logger.error(error);
bridge().showErrorMessageBox(error.message);
}
void CommandService.instance().execute('hideModalMessage');
if (errors.length) {
const response = bridge().showErrorMessageBox('There were some errors importing the notes - check the console for more details.\n\nPlease consider sending a bug report to the forum!', {
buttons: [_('Close'), _('Send bug report')],
});
context.dispatch({ type: 'NOTE_DEVTOOLS_SET', value: true });
if (response === 1) {
const url = makeDiscourseDebugUrl(
`Error importing notes from format: ${options.importFormat}`,
`- Input format: ${options.importFormat}\n- Output format: ${options.outputFormat}`,
errors,
packageInfo,
PluginService.instance(),
Setting.value('plugins.states'),
);
void bridge().openExternal(url);
}
}
},
enabledCondition: '',
};
};

View File

@@ -7,7 +7,6 @@ import * as editAlarm from './editAlarm';
import * as exportPdf from './exportPdf';
import * as gotoAnything from './gotoAnything';
import * as hideModalMessage from './hideModalMessage';
import * as importFrom from './importFrom';
import * as linkToNote from './linkToNote';
import * as moveToFolder from './moveToFolder';
import * as newFolder from './newFolder';
@@ -56,7 +55,6 @@ const index: any[] = [
exportPdf,
gotoAnything,
hideModalMessage,
importFrom,
linkToNote,
moveToFolder,
newFolder,

View File

@@ -1,12 +1,11 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import Folder from '@joplin/lib/models/Folder';
import Folder, { FolderEntityWithChildren } from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
import BaseItem from '@joplin/lib/models/BaseItem';
import { ModelType } from '@joplin/lib/BaseModel';
import Logger from '@joplin/utils/Logger';
import shim from '@joplin/lib/shim';
import showFolderPicker from '../utils/showFolderPicker';
const logger = Logger.create('commands/moveToFolder');
@@ -32,37 +31,71 @@ export const runtime = (comp: any): CommandRuntime => {
}
}
const targetFolderId = await showFolderPicker(comp, {
label: _('Move to notebook:'),
// It's okay for folders (but not notes) to have no parent folder:
allowSelectNone: allAreFolders,
// Don't allow setting a folder as its own parent
showFolder: (folder) => !itemIdToType.has(folder.id),
});
const folders = await Folder.sortFolderTree();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const startFolders: any[] = [];
const maxDepth = 15;
// It's important to allow the case where targetFolderId is the empty string,
// since that corresponds to the toplevel notebook.
if (targetFolderId !== null) {
try {
for (const id of itemIds) {
if (id === targetFolderId) {
continue;
}
const itemType = itemIdToType.get(id);
if (itemType === ModelType.Note) {
await Note.moveToFolder(id, targetFolderId);
} else if (itemType === ModelType.Folder) {
await Folder.moveToFolder(id, targetFolderId);
} else {
throw new Error(`Cannot move item with type ${itemType}`);
}
}
} catch (error) {
logger.error('Error moving items', error);
void shim.showMessageBox(`Error: ${error}`);
}
// It's okay for folders (but not notes) to have no parent folder:
if (allAreFolders) {
startFolders.push({
key: '',
value: '',
label: _('None'),
indentDepth: 0,
});
}
const addOptions = (folders: FolderEntityWithChildren[], depth: number) => {
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
// Disallow making a folder a subfolder of itself.
if (itemIdToType.has(folder.id)) {
continue;
}
startFolders.push({ key: folder.id, value: folder.id, label: folder.title, indentDepth: depth });
if (folder.children) addOptions(folder.children, (depth + 1) < maxDepth ? depth + 1 : maxDepth);
}
};
addOptions(folders, 0);
comp.setState({
promptOptions: {
label: _('Move to notebook:'),
inputType: 'dropdown',
value: '',
autocomplete: startFolders,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onClose: async (answer: any) => {
if (answer) {
try {
const targetFolderId = answer.value;
for (const id of itemIds) {
if (id === targetFolderId) {
continue;
}
const itemType = itemIdToType.get(id);
if (itemType === ModelType.Note) {
await Note.moveToFolder(id, targetFolderId);
} else if (itemType === ModelType.Folder) {
await Folder.moveToFolder(id, targetFolderId);
} else {
throw new Error(`Cannot move item with type ${itemType}`);
}
}
} catch (error) {
logger.error('Error moving items', error);
void shim.showMessageBox(`Error: ${error}`);
}
}
comp.setState({ promptOptions: null });
},
},
});
},
enabledCondition: 'someNotesSelected && !noteIsReadOnly',
};

View File

@@ -1,7 +1,7 @@
import { utils, CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
export const newNoteEnabledConditions = 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
@@ -14,7 +14,7 @@ export const declaration: CommandDeclaration = {
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, body = '', isTodo = false) => {
const folderId = await Folder.getValidActiveFolder();
const folderId = Setting.value('activeFolderId');
if (!folderId) return;
const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false });

View File

@@ -12,15 +12,13 @@ export const declaration: CommandDeclaration = {
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, folderIds: string|string[] = null) => {
if (folderIds === null) folderIds = context.state.selectedFolderIds;
if (!Array.isArray(folderIds)) {
folderIds = [folderIds];
}
execute: async (context: CommandContext, folderId: string = null) => {
if (folderId === null) folderId = context.state.selectedFolderId;
const folders = await Folder.loadItemsByIdsOrFail(folderIds);
await restoreItems(ModelType.Folder, folders);
const folder = await Folder.load(folderId);
if (!folder) throw new Error(`No such folder: ${folderId}`);
await restoreItems(ModelType.Folder, [folder]);
},
enabledCondition: 'foldersAreDeleted',
enabledCondition: 'folderIsDeleted',
};
};

View File

@@ -26,7 +26,6 @@ export interface DialogState {
description?: string;
label?: string;
value?: string;
autocomplete?: unknown;
onClose?: (answer: unknown, buttonType: unknown)=> void;
}|null;
}

View File

@@ -1,56 +0,0 @@
import Folder, { FolderEntityWithChildren } from '@joplin/lib/models/Folder';
import { WindowControl } from './useWindowControl';
import { _ } from '@joplin/lib/locale';
import { FolderEntity } from '@joplin/lib/services/database/types';
interface FolderEntry {
key: string;
value: string;
label: string;
indentDepth: number;
}
interface Options {
label: string;
allowSelectNone: boolean;
showFolder: (entity: FolderEntity)=> boolean;
}
const showFolderPicker = async (control: WindowControl, { label, allowSelectNone, showFolder }: Options) => {
const folders = await Folder.sortFolderTree();
const startFolders: FolderEntry[] = [];
const maxDepth = 15;
if (allowSelectNone) {
startFolders.push({
key: '',
value: '',
label: _('None'),
indentDepth: 0,
});
}
const addOptions = (folders: FolderEntityWithChildren[], depth: number) => {
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
if (!showFolder(folder)) {
continue;
}
startFolders.push({ key: folder.id, value: folder.id, label: folder.title, indentDepth: depth });
if (folder.children) addOptions(folder.children, (depth + 1) < maxDepth ? depth + 1 : maxDepth);
}
};
addOptions(folders, 0);
const folderId = await control.showPrompt({
label,
value: '',
suggestions: startFolders,
});
return folderId;
};
export default showFolderPicker;

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