Compare commits
1 Commits
server-v3.
...
join_serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c44aad544e |
@@ -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
|
||||
|
||||
13
.github/workflows/build-android.yml
vendored
@@ -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: |
|
||||
|
||||
6
.github/workflows/build-macos-m1.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/github-actions-main.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 52 KiB |
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-bullseye
|
||||
FROM node:18-bullseye
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- DONATELINKS -->
|
||||
[](https://www.paypal.com/donate/?hosted_button_id=WQCERTSSLCC7U) [](https://github.com/sponsors/laurent22/) [](https://www.patreon.com/joplin) [](https://joplinapp.org/donate/#donations)
|
||||
[](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¤cy_code=EUR) [](https://github.com/sponsors/laurent22/) [](https://www.patreon.com/joplin) [](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"/>
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
300
packages/app-cli/app/cli-integration-tests.ts
Normal 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);
|
||||
});
|
||||
@@ -107,7 +107,6 @@ class Command extends BaseCommand {
|
||||
userContentBaseUrl: () => joplinServerAuth.userContentBaseUrl,
|
||||
username: () => joplinServerAuth.email,
|
||||
password: () => joplinServerAuth.password,
|
||||
apiKey: () => '',
|
||||
session: (): Session => null,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -1,5 +0,0 @@
|
||||
- [Test!](https://example.com/ "This
|
||||
is a test title
|
||||
testing!
|
||||
Test...")
|
||||
- [Another test...](http://example.com "Test")
|
||||
@@ -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&{\text{if }}n\equiv 0{\pmod {2}},\\3n+1&{\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 </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 </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&{\text{if }}n\equiv 0{\pmod {2}},\\3n+1&{\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;"/>
|
||||
@@ -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}}}$
|
||||
@@ -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>)
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
id: 20250821081408
|
||||
date: 2025-08-21
|
||||
keywords:
|
||||
---
|
||||
|
||||
# A test file for Joplin importer
|
||||
|
||||
Test
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: test
|
||||
created: 2025-07-22 17:30:44Z
|
||||
updated: 2025-07-22 17:37:48Z
|
||||
---
|
||||
|
||||
test
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('app.reducer', () => {
|
||||
...createAppDefaultState({}),
|
||||
backgroundWindows: {
|
||||
testWindow: {
|
||||
...createAppDefaultWindowState(),
|
||||
...createAppDefaultWindowState(null),
|
||||
windowId: 'testWindow',
|
||||
|
||||
visibleDialogs: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
52
packages/app-desktop/commands/convertNoteToMarkdown.ts
Normal 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',
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }}/>;
|
||||
};
|
||||
@@ -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' })} />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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') },
|
||||
};
|
||||
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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']}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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: '',
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface DialogState {
|
||||
description?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
autocomplete?: unknown;
|
||||
onClose?: (answer: unknown, buttonType: unknown)=> void;
|
||||
}|null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||