Compare commits

..
Author SHA1 Message Date
Laurent Cozic 87211d1996 update 2026-07-24 13:16:00 +01:00
Laurent Cozic 26f7166200 update 2026-07-24 13:06:20 +01:00
450 changed files with 6443 additions and 15320 deletions

No files matched your search

-9
View File
@@ -1,9 +0,0 @@
# Environment variables injected by Yarn into every install/build-script run.
# See the injectEnvironmentFiles setting: https://yarnpkg.com/configuration/yarnrc#injectEnvironmentFiles
# onnxruntime-node bundles the CPU runtime we use, but its postinstall also
# downloads the CUDA/GPU execution provider from nuget.org on Linux x64. Joplin
# only does CPU inference, so that download is unused - and it randomly times
# out and fails the whole `yarn install`. Skip it. This is a no-op on
# macOS/Windows, where onnxruntime-node downloads nothing anyway.
ONNXRUNTIME_NODE_INSTALL=skip
+1 -3
View File
@@ -45,9 +45,7 @@ jobs:
- uses: ./.github/workflows/shared/install-yarn
- name: Install
run: |
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
retry yarn install
run: yarn install
env:
SKIP_ONENOTE_CONVERTER_BUILD: 1
+1 -2
View File
@@ -53,8 +53,7 @@ jobs:
run: |
export npm_config_arch=arm64
export npm_config_target_arch=arm64
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
retry yarn install
yarn install
cd packages/app-desktop
npm pkg set 'build.mac.artifactName'='${productName}-${version}-${arch}.${ext}'
+1 -2
View File
@@ -178,8 +178,7 @@ jobs:
echo "RUNNER_ARCH=$RUNNER_ARCH"
echo "DOCKER_IMAGE_PLATFORM=$DOCKER_IMAGE_PLATFORM"
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
retry yarn install
yarn install
yarn buildServerDocker --docker-file Dockerfile.server --platform $DOCKER_IMAGE_PLATFORM --tag-name server-v0.0.0 --repository joplin/server
# Basic test to ensure that the created build is valid. It should exit with
+2 -6
View File
@@ -25,9 +25,7 @@ jobs:
Install-WindowsDeps
- name: Build
if: runner.os != 'Windows'
run: |
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
retry yarn install
run: yarn install
env:
# The onenote-converter package uses Rust, which isn't installed on all CI
# runners. Since the onenote-converter doesn't have UI tests, it can be excluded
@@ -53,9 +51,7 @@ jobs:
- name: Setup build environment
uses: ./.github/workflows/shared/setup-build-environment
- name: Build
run: |
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
retry yarn install
run: yarn install
env:
SKIP_ONENOTE_CONVERTER_BUILD: 1
- name: Run mobile UI tests
+21 -74
View File
@@ -71,8 +71,6 @@ docs/**/*.mustache
!.yarn/sdks
!.yarn/versions
packages/generator-joplin/generators/app/templates/script/publish/dist/
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
packages/app-cli/app/LinkSelector.js
packages/app-cli/app/ResourceServer.js
@@ -85,7 +83,6 @@ packages/app-cli/app/cli-utils.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.test.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-clear.js
packages/app-cli/app/command-config.js
@@ -94,7 +91,6 @@ packages/app-cli/app/command-done.test.js
packages/app-cli/app/command-done.js
packages/app-cli/app/command-dump.js
packages/app-cli/app/command-e2ee.js
packages/app-cli/app/command-edit.test.js
packages/app-cli/app/command-edit.js
packages/app-cli/app/command-exit.js
packages/app-cli/app/command-export-sync-status.js
@@ -119,7 +115,6 @@ packages/app-cli/app/command-rmnote.test.js
packages/app-cli/app/command-rmnote.js
packages/app-cli/app/command-search.js
packages/app-cli/app/command-server.js
packages/app-cli/app/command-set.test.js
packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-share.test.js
@@ -252,8 +247,6 @@ packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
packages/app-desktop/gui/MainLayoutPane.js
packages/app-desktop/gui/MainScreen.js
packages/app-desktop/gui/MainScreen/handleCallbackUrl.test.js
packages/app-desktop/gui/MainScreen/handleCallbackUrl.js
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
packages/app-desktop/gui/MenuBar.js
packages/app-desktop/gui/MultiNoteActions.js
@@ -306,7 +299,6 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
@@ -323,13 +315,10 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/GroupNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/handlePositions.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
packages/app-desktop/gui/NoteEditor/StatusBar.js
packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
@@ -372,7 +361,6 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
packages/app-desktop/gui/NoteEditor/utils/useResourceUnwatcher.js
packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js
packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.test.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
packages/app-desktop/gui/NoteList/NoteList2.js
@@ -420,7 +408,6 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js
packages/app-desktop/gui/NoteSearchBar.js
@@ -515,11 +502,10 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/copyNoteExternalLink.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/disableNoteEncryption.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalRedo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalUndo.js
@@ -528,7 +514,6 @@ 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/lockEncryptedNotes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
@@ -602,20 +587,15 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/dragAndDrop.js
packages/app-desktop/gui/utils/loadScript.js
packages/app-desktop/gulpfile.js
packages/app-desktop/integration-tests/chatPanel.spec.js
packages/app-desktop/integration-tests/goToAnything.spec.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
packages/app-desktop/integration-tests/models/ChatPanel.js
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditor/BaseEditor.js
packages/app-desktop/integration-tests/models/NoteEditor/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/NoteEditor/MarkdownEditor.js
packages/app-desktop/integration-tests/models/NoteEditor/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteEditor/NoteViewer.js
packages/app-desktop/integration-tests/models/NoteEditor/RichTextEditor.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteList.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
@@ -715,7 +695,6 @@ packages/app-mobile/commands/util/goToNote.js
packages/app-mobile/commands/util/showResource.js
packages/app-mobile/components/BetaChip.js
packages/app-mobile/components/BottomDrawer.js
packages/app-mobile/components/BottomDrawerMenu.js
packages/app-mobile/components/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
@@ -877,8 +856,6 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/expo
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.test.js
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.js
packages/app-mobile/components/screens/ConfigScreen/SectionDescription.js
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
packages/app-mobile/components/screens/ConfigScreen/SectionSelector/SectionTab.js
@@ -1134,6 +1111,8 @@ packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
@@ -1164,8 +1143,6 @@ packages/editor/CodeMirror/extensions/rendering/replaceLinks.test.js
packages/editor/CodeMirror/extensions/rendering/replaceLinks.js
packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeHideReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.test.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
packages/editor/CodeMirror/extensions/searchExtension.test.js
@@ -1184,10 +1161,7 @@ packages/editor/CodeMirror/testing/forceFullParse.js
packages/editor/CodeMirror/testing/loadLanguages.js
packages/editor/CodeMirror/testing/pressReleaseKey.js
packages/editor/CodeMirror/testing/typeText.js
packages/editor/CodeMirror/testing/visibleEditorText.js
packages/editor/CodeMirror/theme.js
packages/editor/CodeMirror/utils/findPositionMatchingLink.test.js
packages/editor/CodeMirror/utils/findPositionMatchingLink.js
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.test.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
@@ -1316,6 +1290,9 @@ packages/generator-joplin/generators/app/templates/api/index.js
packages/generator-joplin/generators/app/templates/api/noteListType.js
packages/generator-joplin/generators/app/templates/api/types.js
packages/generator-joplin/generators/app/templates/api_index.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
packages/generator-joplin/generators/app/templates/src/index.js
packages/generator-joplin/tools/updateCategories.js
packages/htmlpack/index.test.js
@@ -1333,7 +1310,6 @@ packages/lib/BaseModel.test.js
packages/lib/BaseModel.js
packages/lib/BaseSyncTarget.js
packages/lib/Cache.js
packages/lib/ClipperServer.test.js
packages/lib/ClipperServer.js
packages/lib/CssUtils.js
packages/lib/EventDispatcher.test.js
@@ -1404,7 +1380,6 @@ packages/lib/components/shared/SamlShared.js
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
packages/lib/components/shared/ShareNoteDialog/types.js
packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
packages/lib/components/shared/ShareNoteDialog/useIsPublished.js
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
packages/lib/components/shared/SsoScreenShared.js
@@ -1419,7 +1394,6 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
packages/lib/components/shared/dropbox-login-shared.js
packages/lib/components/shared/note-screen-shared.test.js
packages/lib/components/shared/note-screen-shared.js
packages/lib/components/shared/reduxSharedMiddleware.js
packages/lib/components/shared/side-menu-shared.test.js
@@ -1495,7 +1469,6 @@ packages/lib/models/Alarm.js
packages/lib/models/BaseItem.test.js
packages/lib/models/BaseItem.js
packages/lib/models/ConflictNoteState.js
packages/lib/models/Folder.publishing.test.js
packages/lib/models/Folder.sharing.test.js
packages/lib/models/Folder.test.js
packages/lib/models/Folder.js
@@ -1613,13 +1586,13 @@ packages/lib/services/ai/SearchService.js
packages/lib/services/ai/SqliteVec.test.js
packages/lib/services/ai/aiSettingsTransition.test.js
packages/lib/services/ai/aiSettingsTransition.js
packages/lib/services/ai/applyNoteEdits.js
packages/lib/services/ai/availability.test.js
packages/lib/services/ai/availability.js
packages/lib/services/ai/chunker.test.js
packages/lib/services/ai/chunker.js
packages/lib/services/ai/classification.js
packages/lib/services/ai/noteChat.test.js
packages/lib/services/ai/noteChat.tools.test.js
packages/lib/services/ai/noteChat.js
packages/lib/services/ai/providers/Anthropic.test.js
packages/lib/services/ai/providers/Anthropic.js
@@ -1630,26 +1603,6 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
packages/lib/services/ai/providers/TestProvider.js
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
packages/lib/services/ai/testing/TestEmbeddingProvider.js
packages/lib/services/ai/tools/ToolIndex.js
packages/lib/services/ai/tools/buildEditorTools.js
packages/lib/services/ai/tools/global/createNote.js
packages/lib/services/ai/tools/global/createNotebook.js
packages/lib/services/ai/tools/global/deleteNote.js
packages/lib/services/ai/tools/global/index.js
packages/lib/services/ai/tools/global/listNotebooks.js
packages/lib/services/ai/tools/global/listTags.js
packages/lib/services/ai/tools/global/manageTags.js
packages/lib/services/ai/tools/global/readImage.js
packages/lib/services/ai/tools/global/readNote.js
packages/lib/services/ai/tools/global/searchNotes.js
packages/lib/services/ai/tools/global/semanticSearchNotes.js
packages/lib/services/ai/tools/global/updateNote.js
packages/lib/services/ai/tools/types.js
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
packages/lib/services/ai/tools/utils/applyNoteEdits.js
packages/lib/services/ai/tools/utils/buildTool.js
packages/lib/services/ai/tools/utils/serializeToolOutput.js
packages/lib/services/ai/tools/utils/settings.js
packages/lib/services/ai/types.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/commands/MenuUtils.js
@@ -1661,13 +1614,6 @@ packages/lib/services/commands/isEditorCommand.js
packages/lib/services/commands/propsHaveChanged.js
packages/lib/services/commands/stateToWhenClauseContext.test.js
packages/lib/services/commands/stateToWhenClauseContext.js
packages/lib/services/conflict/diffNotes.test.js
packages/lib/services/conflict/diffNotes.js
packages/lib/services/conflict/isConflictResolutionEnabled.js
packages/lib/services/conflict/loadConflictData.test.js
packages/lib/services/conflict/loadConflictData.js
packages/lib/services/conflict/wordDiff.test.js
packages/lib/services/conflict/wordDiff.js
packages/lib/services/contextkey/contextkey.js
packages/lib/services/database/addMigrationFile.js
packages/lib/services/database/isSqliteSyntaxError.js
@@ -1720,15 +1666,12 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js
packages/lib/services/interop/InteropService_Importer_Base.js
packages/lib/services/interop/InteropService_Importer_Custom.js
packages/lib/services/interop/InteropService_Importer_EnexToHtml.js
packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js
packages/lib/services/interop/InteropService_Importer_EnexToMd.js
packages/lib/services/interop/InteropService_Importer_Jex.js
packages/lib/services/interop/InteropService_Importer_Md.test.js
packages/lib/services/interop/InteropService_Importer_Md.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
packages/lib/services/interop/InteropService_Importer_Obsidian.test.js
packages/lib/services/interop/InteropService_Importer_Obsidian.js
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.js
@@ -1750,6 +1693,17 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js
packages/lib/services/keychain/KeychainServiceDriverBase.js
packages/lib/services/mcp/McpServer.test.js
packages/lib/services/mcp/McpServer.js
packages/lib/services/mcp/registry.js
packages/lib/services/mcp/tools/createNote.js
packages/lib/services/mcp/tools/createNotebook.js
packages/lib/services/mcp/tools/deleteNote.js
packages/lib/services/mcp/tools/listNotebooks.js
packages/lib/services/mcp/tools/listTags.js
packages/lib/services/mcp/tools/manageTags.js
packages/lib/services/mcp/tools/readNote.js
packages/lib/services/mcp/tools/searchNotes.js
packages/lib/services/mcp/tools/semanticSearchNotes.js
packages/lib/services/mcp/tools/updateNote.js
packages/lib/services/mcp/types.js
packages/lib/services/noteList/checkboxPieCss.js
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
@@ -1767,10 +1721,7 @@ packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
packages/lib/services/noteLock/NoteLockSession.test.js
packages/lib/services/noteLock/NoteLockSession.js
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
packages/lib/services/noteLock/isNoteLockEnabled.js
packages/lib/services/noteLock/setNoteLockState.test.js
packages/lib/services/noteLock/setNoteLockState.js
packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
@@ -1873,7 +1824,6 @@ packages/lib/services/rest/routes/notes.test.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
packages/lib/services/rest/routes/resources.js
packages/lib/services/rest/routes/revisions.test.js
packages/lib/services/rest/routes/revisions.js
packages/lib/services/rest/routes/search.js
packages/lib/services/rest/routes/tags.js
@@ -1887,7 +1837,6 @@ packages/lib/services/rest/utils/readonlyProperties.js
packages/lib/services/rest/utils/requestFields.js
packages/lib/services/rest/utils/requestPaginationOptions.js
packages/lib/services/search/SearchEngine.resources.test.js
packages/lib/services/search/SearchEngine.semantic.test.js
packages/lib/services/search/SearchEngine.test.js
packages/lib/services/search/SearchEngine.js
packages/lib/services/search/SearchEngineUtils.test.js
@@ -1968,7 +1917,6 @@ packages/lib/shim-init-node.js
packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/ai/semanticSearch.js
packages/lib/testing/dom-test-environment.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js
@@ -2124,7 +2072,6 @@ packages/tools/checkIgnoredFiles.js
packages/tools/checkLibPaths.test.js
packages/tools/checkLibPaths.js
packages/tools/convertThemesToCss.js
packages/tools/eslint-rules/describeFilename.test.js
packages/tools/fuzzer/ActionRunner.js
packages/tools/fuzzer/Fuzzer.js
packages/tools/fuzzer/cli.js
@@ -2202,8 +2149,8 @@ packages/tools/updateMarkdownDoc.js
packages/tools/utils/discourse.test.js
packages/tools/utils/discourse.js
packages/tools/utils/loadSponsors.js
packages/tools/utils/parsePluralLocalizationForm.test.js
packages/tools/utils/parsePluralLocalizationForm.js
packages/tools/utils/parsePlurallLocalizationForm.test.js
packages/tools/utils/translation.js
packages/tools/validateFilenames.js
packages/tools/website/build.js
+21 -74
View File
@@ -8,7 +8,6 @@ _releases/
**/node_modules/
**/abcjs-basic-min.js
packages/generator-joplin/generators/app/templates/api/
packages/generator-joplin/generators/app/templates/script/publish/dist/
Assets/
docs/
highlight.pack.js
@@ -83,7 +82,6 @@ packages/lib/welcomeAssets.js
packages/editor/*/vendor/
packages/plugins/**/api
packages/plugins/**/dist
packages/plugin-repo-cli/dist/
packages/server/dist/
packages/utils/dist/
packages/transcribe/dist/
@@ -111,7 +109,6 @@ packages/app-cli/app/cli-utils.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.test.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-clear.js
packages/app-cli/app/command-config.js
@@ -120,7 +117,6 @@ packages/app-cli/app/command-done.test.js
packages/app-cli/app/command-done.js
packages/app-cli/app/command-dump.js
packages/app-cli/app/command-e2ee.js
packages/app-cli/app/command-edit.test.js
packages/app-cli/app/command-edit.js
packages/app-cli/app/command-exit.js
packages/app-cli/app/command-export-sync-status.js
@@ -145,7 +141,6 @@ packages/app-cli/app/command-rmnote.test.js
packages/app-cli/app/command-rmnote.js
packages/app-cli/app/command-search.js
packages/app-cli/app/command-server.js
packages/app-cli/app/command-set.test.js
packages/app-cli/app/command-set.js
packages/app-cli/app/command-settingschema.js
packages/app-cli/app/command-share.test.js
@@ -278,8 +273,6 @@ packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
packages/app-desktop/gui/MainLayoutPane.js
packages/app-desktop/gui/MainScreen.js
packages/app-desktop/gui/MainScreen/handleCallbackUrl.test.js
packages/app-desktop/gui/MainScreen/handleCallbackUrl.js
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
packages/app-desktop/gui/MenuBar.js
packages/app-desktop/gui/MultiNoteActions.js
@@ -332,7 +325,6 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
@@ -349,13 +341,10 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/GroupNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/handlePositions.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
packages/app-desktop/gui/NoteEditor/StatusBar.js
packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
@@ -398,7 +387,6 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
packages/app-desktop/gui/NoteEditor/utils/useResourceUnwatcher.js
packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js
packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.test.js
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
packages/app-desktop/gui/NoteList/NoteList2.js
@@ -446,7 +434,6 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
packages/app-desktop/gui/NotePropertiesDialog.js
packages/app-desktop/gui/NoteRevisionViewer.js
packages/app-desktop/gui/NoteSearchBar.js
@@ -541,11 +528,10 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/copyNoteExternalLink.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/disableNoteEncryption.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalRedo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalUndo.js
@@ -554,7 +540,6 @@ 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/lockEncryptedNotes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
@@ -628,20 +613,15 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/dragAndDrop.js
packages/app-desktop/gui/utils/loadScript.js
packages/app-desktop/gulpfile.js
packages/app-desktop/integration-tests/chatPanel.spec.js
packages/app-desktop/integration-tests/goToAnything.spec.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
packages/app-desktop/integration-tests/models/ChatPanel.js
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditor/BaseEditor.js
packages/app-desktop/integration-tests/models/NoteEditor/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/NoteEditor/MarkdownEditor.js
packages/app-desktop/integration-tests/models/NoteEditor/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteEditor/NoteViewer.js
packages/app-desktop/integration-tests/models/NoteEditor/RichTextEditor.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteList.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
@@ -741,7 +721,6 @@ packages/app-mobile/commands/util/goToNote.js
packages/app-mobile/commands/util/showResource.js
packages/app-mobile/components/BetaChip.js
packages/app-mobile/components/BottomDrawer.js
packages/app-mobile/components/BottomDrawerMenu.js
packages/app-mobile/components/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
@@ -903,8 +882,6 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/expo
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.test.js
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.js
packages/app-mobile/components/screens/ConfigScreen/SectionDescription.js
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
packages/app-mobile/components/screens/ConfigScreen/SectionSelector/SectionTab.js
@@ -1160,6 +1137,8 @@ packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
@@ -1190,8 +1169,6 @@ packages/editor/CodeMirror/extensions/rendering/replaceLinks.test.js
packages/editor/CodeMirror/extensions/rendering/replaceLinks.js
packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeHideReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.test.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
packages/editor/CodeMirror/extensions/searchExtension.test.js
@@ -1210,10 +1187,7 @@ packages/editor/CodeMirror/testing/forceFullParse.js
packages/editor/CodeMirror/testing/loadLanguages.js
packages/editor/CodeMirror/testing/pressReleaseKey.js
packages/editor/CodeMirror/testing/typeText.js
packages/editor/CodeMirror/testing/visibleEditorText.js
packages/editor/CodeMirror/theme.js
packages/editor/CodeMirror/utils/findPositionMatchingLink.test.js
packages/editor/CodeMirror/utils/findPositionMatchingLink.js
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.test.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
@@ -1342,6 +1316,9 @@ packages/generator-joplin/generators/app/templates/api/index.js
packages/generator-joplin/generators/app/templates/api/noteListType.js
packages/generator-joplin/generators/app/templates/api/types.js
packages/generator-joplin/generators/app/templates/api_index.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
packages/generator-joplin/generators/app/templates/src/index.js
packages/generator-joplin/tools/updateCategories.js
packages/htmlpack/index.test.js
@@ -1359,7 +1336,6 @@ packages/lib/BaseModel.test.js
packages/lib/BaseModel.js
packages/lib/BaseSyncTarget.js
packages/lib/Cache.js
packages/lib/ClipperServer.test.js
packages/lib/ClipperServer.js
packages/lib/CssUtils.js
packages/lib/EventDispatcher.test.js
@@ -1430,7 +1406,6 @@ packages/lib/components/shared/SamlShared.js
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
packages/lib/components/shared/ShareNoteDialog/types.js
packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
packages/lib/components/shared/ShareNoteDialog/useIsPublished.js
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
packages/lib/components/shared/SsoScreenShared.js
@@ -1445,7 +1420,6 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
packages/lib/components/shared/dropbox-login-shared.js
packages/lib/components/shared/note-screen-shared.test.js
packages/lib/components/shared/note-screen-shared.js
packages/lib/components/shared/reduxSharedMiddleware.js
packages/lib/components/shared/side-menu-shared.test.js
@@ -1521,7 +1495,6 @@ packages/lib/models/Alarm.js
packages/lib/models/BaseItem.test.js
packages/lib/models/BaseItem.js
packages/lib/models/ConflictNoteState.js
packages/lib/models/Folder.publishing.test.js
packages/lib/models/Folder.sharing.test.js
packages/lib/models/Folder.test.js
packages/lib/models/Folder.js
@@ -1639,13 +1612,13 @@ packages/lib/services/ai/SearchService.js
packages/lib/services/ai/SqliteVec.test.js
packages/lib/services/ai/aiSettingsTransition.test.js
packages/lib/services/ai/aiSettingsTransition.js
packages/lib/services/ai/applyNoteEdits.js
packages/lib/services/ai/availability.test.js
packages/lib/services/ai/availability.js
packages/lib/services/ai/chunker.test.js
packages/lib/services/ai/chunker.js
packages/lib/services/ai/classification.js
packages/lib/services/ai/noteChat.test.js
packages/lib/services/ai/noteChat.tools.test.js
packages/lib/services/ai/noteChat.js
packages/lib/services/ai/providers/Anthropic.test.js
packages/lib/services/ai/providers/Anthropic.js
@@ -1656,26 +1629,6 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
packages/lib/services/ai/providers/TestProvider.js
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
packages/lib/services/ai/testing/TestEmbeddingProvider.js
packages/lib/services/ai/tools/ToolIndex.js
packages/lib/services/ai/tools/buildEditorTools.js
packages/lib/services/ai/tools/global/createNote.js
packages/lib/services/ai/tools/global/createNotebook.js
packages/lib/services/ai/tools/global/deleteNote.js
packages/lib/services/ai/tools/global/index.js
packages/lib/services/ai/tools/global/listNotebooks.js
packages/lib/services/ai/tools/global/listTags.js
packages/lib/services/ai/tools/global/manageTags.js
packages/lib/services/ai/tools/global/readImage.js
packages/lib/services/ai/tools/global/readNote.js
packages/lib/services/ai/tools/global/searchNotes.js
packages/lib/services/ai/tools/global/semanticSearchNotes.js
packages/lib/services/ai/tools/global/updateNote.js
packages/lib/services/ai/tools/types.js
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
packages/lib/services/ai/tools/utils/applyNoteEdits.js
packages/lib/services/ai/tools/utils/buildTool.js
packages/lib/services/ai/tools/utils/serializeToolOutput.js
packages/lib/services/ai/tools/utils/settings.js
packages/lib/services/ai/types.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/commands/MenuUtils.js
@@ -1687,13 +1640,6 @@ packages/lib/services/commands/isEditorCommand.js
packages/lib/services/commands/propsHaveChanged.js
packages/lib/services/commands/stateToWhenClauseContext.test.js
packages/lib/services/commands/stateToWhenClauseContext.js
packages/lib/services/conflict/diffNotes.test.js
packages/lib/services/conflict/diffNotes.js
packages/lib/services/conflict/isConflictResolutionEnabled.js
packages/lib/services/conflict/loadConflictData.test.js
packages/lib/services/conflict/loadConflictData.js
packages/lib/services/conflict/wordDiff.test.js
packages/lib/services/conflict/wordDiff.js
packages/lib/services/contextkey/contextkey.js
packages/lib/services/database/addMigrationFile.js
packages/lib/services/database/isSqliteSyntaxError.js
@@ -1746,15 +1692,12 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js
packages/lib/services/interop/InteropService_Importer_Base.js
packages/lib/services/interop/InteropService_Importer_Custom.js
packages/lib/services/interop/InteropService_Importer_EnexToHtml.js
packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js
packages/lib/services/interop/InteropService_Importer_EnexToMd.js
packages/lib/services/interop/InteropService_Importer_Jex.js
packages/lib/services/interop/InteropService_Importer_Md.test.js
packages/lib/services/interop/InteropService_Importer_Md.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
packages/lib/services/interop/InteropService_Importer_Obsidian.test.js
packages/lib/services/interop/InteropService_Importer_Obsidian.js
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.js
@@ -1776,6 +1719,17 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js
packages/lib/services/keychain/KeychainServiceDriverBase.js
packages/lib/services/mcp/McpServer.test.js
packages/lib/services/mcp/McpServer.js
packages/lib/services/mcp/registry.js
packages/lib/services/mcp/tools/createNote.js
packages/lib/services/mcp/tools/createNotebook.js
packages/lib/services/mcp/tools/deleteNote.js
packages/lib/services/mcp/tools/listNotebooks.js
packages/lib/services/mcp/tools/listTags.js
packages/lib/services/mcp/tools/manageTags.js
packages/lib/services/mcp/tools/readNote.js
packages/lib/services/mcp/tools/searchNotes.js
packages/lib/services/mcp/tools/semanticSearchNotes.js
packages/lib/services/mcp/tools/updateNote.js
packages/lib/services/mcp/types.js
packages/lib/services/noteList/checkboxPieCss.js
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
@@ -1793,10 +1747,7 @@ packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
packages/lib/services/noteLock/NoteLockSession.test.js
packages/lib/services/noteLock/NoteLockSession.js
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
packages/lib/services/noteLock/isNoteLockEnabled.js
packages/lib/services/noteLock/setNoteLockState.test.js
packages/lib/services/noteLock/setNoteLockState.js
packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
@@ -1899,7 +1850,6 @@ packages/lib/services/rest/routes/notes.test.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
packages/lib/services/rest/routes/resources.js
packages/lib/services/rest/routes/revisions.test.js
packages/lib/services/rest/routes/revisions.js
packages/lib/services/rest/routes/search.js
packages/lib/services/rest/routes/tags.js
@@ -1913,7 +1863,6 @@ packages/lib/services/rest/utils/readonlyProperties.js
packages/lib/services/rest/utils/requestFields.js
packages/lib/services/rest/utils/requestPaginationOptions.js
packages/lib/services/search/SearchEngine.resources.test.js
packages/lib/services/search/SearchEngine.semantic.test.js
packages/lib/services/search/SearchEngine.test.js
packages/lib/services/search/SearchEngine.js
packages/lib/services/search/SearchEngineUtils.test.js
@@ -1994,7 +1943,6 @@ packages/lib/shim-init-node.js
packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/ai/semanticSearch.js
packages/lib/testing/dom-test-environment.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js
@@ -2150,7 +2098,6 @@ packages/tools/checkIgnoredFiles.js
packages/tools/checkLibPaths.test.js
packages/tools/checkLibPaths.js
packages/tools/convertThemesToCss.js
packages/tools/eslint-rules/describeFilename.test.js
packages/tools/fuzzer/ActionRunner.js
packages/tools/fuzzer/Fuzzer.js
packages/tools/fuzzer/cli.js
@@ -2228,8 +2175,8 @@ packages/tools/updateMarkdownDoc.js
packages/tools/utils/discourse.test.js
packages/tools/utils/discourse.js
packages/tools/utils/loadSponsors.js
packages/tools/utils/parsePluralLocalizationForm.test.js
packages/tools/utils/parsePluralLocalizationForm.js
packages/tools/utils/parsePlurallLocalizationForm.test.js
packages/tools/utils/translation.js
packages/tools/validateFilenames.js
packages/tools/website/build.js
@@ -0,0 +1,90 @@
# This patch's goal is to work around an issue in the NSIS uninstaller on Windows:
# - For future uninstallers, this patch backports an upstream commit that changes how
# running copies of the app are found.
# - See https://github.com/electron-userland/electron-builder/pull/8133
# - If an existing uninstaller fails, gives an option to continue with the installation
# despite the failure.
# - Updates "uninstall failed" error messages to state that uninstallation failed (rather
# than incorrectly stating that the issue was with closing the app).
#
# See https://github.com/laurent22/joplin/pull/11541
diff --git a/templates/nsis/include/allowOnlyOneInstallerInstance.nsh b/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
index fe5d45c730f36c9fe8d8cfea12e242e501b67139..97b27fce6798e30e3e631221435f09b3579e77c3 100644
--- a/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
+++ b/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
@@ -42,7 +42,7 @@
${nsProcess::FindProcess} "${_FILE}" ${_ERR}
!else
# find process owned by current user
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c tasklist /FI "USERNAME eq %USERNAME%" /FI "IMAGENAME eq ${_FILE}" /FO csv | %SYSTEMROOT%\System32\find.exe "${_FILE}"`
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c tasklist /FI "USERNAME eq %USERNAME%" /FI "IMAGENAME eq ${_FILE}" /FO csv | "$SYSDIR\find.exe" "${_FILE}"`
Pop ${_ERR}
!endif
!macroend
@@ -73,7 +73,7 @@
!ifdef INSTALL_MODE_PER_ALL_USERS
nsExec::Exec `taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid"`
!else
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
!endif
# to ensure that files are not "in-use"
Sleep 300
@@ -91,7 +91,7 @@
!ifdef INSTALL_MODE_PER_ALL_USERS
nsExec::Exec `taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid"`
!else
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
!endif
!insertmacro FIND_PROCESS "${APP_EXECUTABLE_FILENAME}" $R0
${If} $R0 == 0
diff --git a/templates/nsis/include/installUtil.nsh b/templates/nsis/include/installUtil.nsh
index 47367741632726ba0886ac516461dbe98b7aea58..675965762375925a505ca6d8bbb67507ef696c2e 100644
--- a/templates/nsis/include/installUtil.nsh
+++ b/templates/nsis/include/installUtil.nsh
@@ -126,10 +126,11 @@ Function handleUninstallResult
Return
${if} $R0 != 0
- MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
+ # MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
DetailPrint `Uninstall was not successful. Uninstaller error code: $R0.`
- SetErrorLevel 2
- Quit
+ DetailPrint `Continuing anyway. See https://github.com/laurent22/joplin/pull/11612.`
+ # SetErrorLevel 2
+ # Quit
${endif}
FunctionEnd
@@ -216,11 +217,13 @@ Function uninstallOldVersion
IntOp $R5 $R5 + 1
${if} $R5 > 5
- MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY OneMoreAttempt
- Return
+ MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeUninstalled)" /SD IDCANCEL IDRETRY ContinueWithoutUninstall
+ Abort ; Exit early
+ ContinueWithoutUninstall:
+ Return
${endIf}
- OneMoreAttempt:
+# OneMoreAttempt: ; Commented out because unused
ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0
ifErrors TryInPlace CheckResult
diff --git a/templates/nsis/messages.yml b/templates/nsis/messages.yml
index a1c2847fa48d79f835b30b48e999ccaf3c818657..6884c18d1e77dbd6be114401d23cf5caf3e0dd94 100644
--- a/templates/nsis/messages.yml
+++ b/templates/nsis/messages.yml
@@ -235,3 +235,8 @@ uninstallFailed:
sv: Det gick inte att avinstallera gamla programfiler. Försök att köra installationsprogrammet igen.
uk: Не вдалось видалити старі файли застосунку. Будь ласка, спробуйте запустити встановлювач знов.
zh_TW: 無法俺安裝舊的應用程式檔案。 請嘗試再次執行安裝程式。
+
+
+appCannotBeUninstalled:
+ en: "The old version of ${PRODUCT_NAME} could not be removed. \nClick Retry to skip this step."
+
@@ -1,48 +0,0 @@
# Patched to work around a Windows install issue: On Windows, if an existing uninstaller fails,
# this change gives an option to continue with the installation despite the failure
# (https://github.com/laurent22/joplin/pull/11541).
diff --git a/templates/nsis/include/installUtil.nsh b/templates/nsis/include/installUtil.nsh
index 47367741632726ba0886ac516461dbe98b7aea58..c40a0907688cd8b1b8d09047cb0b380e42110ea1 100644
--- a/templates/nsis/include/installUtil.nsh
+++ b/templates/nsis/include/installUtil.nsh
@@ -128,8 +128,9 @@ Function handleUninstallResult
${if} $R0 != 0
- MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
+ # MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
DetailPrint `Uninstall was not successful. Uninstaller error code: $R0.`
- SetErrorLevel 2
- Quit
+ DetailPrint `Continuing anyway. See https://github.com/laurent22/joplin/pull/11612.`
+ # SetErrorLevel 2
+ # Quit
${endif}
FunctionEnd
@@ -216,11 +217,13 @@ Function uninstallOldVersion
IntOp $R5 $R5 + 1
${if} $R5 > 5
- MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY OneMoreAttempt
- Return
+ MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeUninstalled)" /SD IDCANCEL IDRETRY ContinueWithoutUninstall
+ Abort ; Exit early
+ ContinueWithoutUninstall:
+ Return
${endIf}
- OneMoreAttempt:
+# OneMoreAttempt: ; OneMoreAttempt is unused
ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0
ifErrors TryInPlace CheckResult
diff --git a/templates/nsis/messages.yml b/templates/nsis/messages.yml
index 982e1b09e424c4f07ce7c1152fe5ce8b703f5f57..20dfa5743d18f8775ce88658014092cf977ad055 100644
--- a/templates/nsis/messages.yml
+++ b/templates/nsis/messages.yml
@@ -238,3 +238,5 @@ uninstallFailed:
appClosing:
en: "Closing running ${PRODUCT_NAME}..."
cs: "Ukončuji aplikaci ${PRODUCT_NAME}..."
+appCannotBeUninstalled:
+ en: "The old version of ${PRODUCT_NAME} could not be removed. \nClick Retry to skip this step."
\ No newline at end of file
+5 -16
View File
@@ -1212,13 +1212,6 @@ footer .bottom-links-row p {
margin-top: 2em;
}
.price-container p.pricing-table-billed-annually {
font-size: 14px;
font-style: italic;
opacity: 0.7;
margin-top: 0.5em;
}
.price-row {
display: flex;
flex-direction: row;
@@ -1311,15 +1304,6 @@ footer .bottom-links-row p {
opacity: 0.4;
}
.cfa-button {
margin-top: 10px;
}
.cfa-description {
font-style: italic;
text-align: center;
}
@media (max-width: 1400px) {
.price-row .plan-type {
font-size: 16px;
@@ -1408,3 +1392,8 @@ footer .bottom-links-row p {
:lang(zh-cn) #plans-section .faq {
display: none;
}
.cfa-button {
margin-top: 10px;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 KiB

@@ -40,16 +40,12 @@
{{#pricingTable}}
<strong translate>Pricing</strong>
<table class="table">
<thead>
<tr><th translate>Users</th><th translate>Price</th></tr>
</thead>
<tbody>
{{#rows}}
<tr><td>{{condition}}</td><td>{{priceMonthly}}</td></tr>
<tr><td>{{condition}}</td><td>{{priceYearly}}</td></tr>
{{/rows}}
</tbody>
</table>
<p class="pricing-table-billed-annually">{{billedAnnually}}</p>
{{/pricingTable}}
<p class="text-center subscribe-wrapper">
@@ -58,10 +54,6 @@
{{#learnMoreUrl}}
<a id="learnMore-{{name}}" href="{{learnMoreUrl}}" class="button-link btn-white learnMoreButton cfa-button">Learn more</a>
{{/learnMoreUrl}}
{{#cfaDescription}}
<p class="cfa-description">{{cfaDescription}}</p>
{{/cfaDescription}}
</p>
{{#footnote}}<sub>(*) {{.}}</sub>{{/footnote}}
@@ -88,7 +88,7 @@
Joplin Server Business
</h1>
<p translate class="text-center sub-title">
Joplin Server Business is the self-hosted solution for businesses and organisations. Install it on your own infrastructure, keeping your data private and secure within your organisation while retaining full control over where it is stored.
Joplin Server Business is a synchronisation server that you can install on your own infrastructure, so that your data remains private and secure within your business.
</p>
</div>
</div>
@@ -237,7 +237,7 @@
});
});
const setHostingType = (type, updateUrl = true) => {
const setHostingType = (type) => {
const other = type === 'managed' ? 'self' : 'managed';
$('.toggle-button-' + type).addClass('active');
$('.toggle-button-' + type).removeClass('inactive');
@@ -246,12 +246,6 @@
$('.hosting-type-' + type).show();
$('.hosting-type-' + other).hide();
if (updateUrl) {
const url = new URL(location.href);
url.searchParams.set('hosting', type);
history.replaceState(null, '', url);
}
}
$('.toggle-button-managed').click((event) => {
@@ -267,6 +261,6 @@
});
const initialHostingType = urlQuery.get('hosting') ? urlQuery.get('hosting') : 'managed';
setHostingType(initialHostingType, false);
setHostingType(initialHostingType);
</script>
</div>
+1 -3
View File
@@ -102,15 +102,13 @@ WORKDIR /home/$user/packages/server
ENTRYPOINT ["tini", "--"]
CMD ["yarn", "start-prod"]
ARG IMAGE_NAME="Joplin Server"
ENV APP_NAME=${IMAGE_NAME}
# Build-time metadata
# https://github.com/opencontainers/image-spec/blob/master/annotations.md
ARG BUILD_DATE
ARG REVISION
ARG VERSION
ARG SOURCE
ARG IMAGE_NAME="Joplin Server"
LABEL org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.title="$IMAGE_NAME" \
org.opencontainers.image.description="Docker image for Joplin Server" \
+18 -21
View File
@@ -172,6 +172,24 @@ elif [[ $ARCHITECTURE =~ .*i386.*|.*i686.* ]]; then
fi
#-----------------------------------------------------
print "Checking dependencies..."
## Check if libfuse2 is present.
if [[ $(command -v ldconfig) ]]; then
LIBFUSE=$(ldconfig -p | grep "libfuse.so.2" || echo '')
fi
if [[ $LIBFUSE == "" ]]; then
LIBFUSE=$(find /lib /usr/lib /lib64 /usr/lib64 /usr/local/lib -name "libfuse.so.2" 2>/dev/null | grep "libfuse.so.2" || echo '')
fi
if [[ $LIBFUSE == "" ]]; then
print "${COLOR_RED}Error: Can't get libfuse2 on system, please install libfuse2${COLOR_RESET}"
print "See https://joplinapp.org/help/faq/#desktop-application-will-not-launch-on-linux and https://github.com/AppImage/AppImageKit/wiki/FUSE for more information"
exit 1
fi
#-----------------------------------------------------
# Download Joplin
#-----------------------------------------------------
# Get the latest version to download
if [[ "$INCLUDE_PRE_RELEASE" == true ]]; then
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases" | grep -Po '"tag_name": ?"v\K.*?(?=")' | sort -rV | head -1)
@@ -179,27 +197,6 @@ else
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases/latest" | grep -Po '"tag_name": ?"v\K.*?(?=")')
fi
#-----------------------------------------------------
print "Checking dependencies..."
## Check for libfuse2 for Joplin versions lesser than 3.7.10, which transitioned to a new AppImage runtime without libfuse2 dependency
if [[ $(compareVersions "$RELEASE_VERSION" "3.7.10") -lt 0 ]]; then
if [[ $(command -v ldconfig) ]]; then
LIBFUSE=$(ldconfig -p | grep "libfuse.so.2" || echo '')
fi
if [[ $LIBFUSE == "" ]]; then
LIBFUSE=$(find /lib /usr/lib /lib64 /usr/lib64 /usr/local/lib -name "libfuse.so.2" 2>/dev/null | grep "libfuse.so.2" || echo '')
fi
if [[ $LIBFUSE == "" ]]; then
print "${COLOR_RED}Error: Can't get libfuse2 on system, please install libfuse2${COLOR_RESET}"
print "See https://joplinapp.org/help/faq/#desktop-application-will-not-launch-on-linux and https://github.com/AppImage/AppImageKit/wiki/FUSE for more information"
exit 1
fi
fi
#-----------------------------------------------------
# Download Joplin
#-----------------------------------------------------
# Check if it's in the latest version
if [[ -e "${INSTALL_DIR}/VERSION" ]]; then
CURRENT_VERSION=$(< "${INSTALL_DIR}/VERSION")
+1 -1
View File
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
# Sponsors
<!-- SPONSORS-ORG -->
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://socialboom.io/"><img title="Buy YouTube, Instagram, TikTok &amp; Facebook Growth Services at Socialboom" width="256" src="https://joplinapp.org/images/sponsors/Socialboom.png" alt="Buy YouTube, Instagram, TikTok &amp; Facebook Growth Services at Socialboom"/></a> <a href="https://essaypro.com/"><img title="write my essay EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write my essay EssayPro"/></a> <a href="https://writepaper.com/"><img title="write my paper" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="write my paper"/></a> <a href="https://mypaperhelp.com/"><img title="MyPaperHelp" width="256" src="https://joplinapp.org/images/sponsors/MyPaperHelp.png" alt="MyPaperHelp"/></a> <a href="https://essaypay.com/"><img title="EssayPay" width="256" src="https://joplinapp.org/images/sponsors/EssayPay.png" alt="EssayPay"/></a>
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://socialboom.io/"><img title="Buy YouTube, Instagram, TikTok &amp; Facebook Growth Services at Socialboom" width="256" src="https://joplinapp.org/images/sponsors/Socialboom.png" alt="Buy YouTube, Instagram, TikTok &amp; Facebook Growth Services at Socialboom"/></a>
<!-- SPONSORS-ORG -->
* * *
+1 -1
View File
@@ -11,7 +11,7 @@
},
"nodejs": "24.12.0",
"pkg-config": "latest",
"python": "3.14.3",
"python": "3.14.2",
"bat": "latest",
"electron": {
"version": "latest",
-9
View File
@@ -14,8 +14,6 @@ const github = require('eslint-plugin-github');
const { fixupPluginRules, includeIgnoreFile } = require('@eslint/compat');
const js = require('@eslint/js');
const joplin = require('./packages/tools/eslint-rules');
const { join } = require('path');
// As of ESLint v9, .eslintignore is no longer supported and the presence of
@@ -248,7 +246,6 @@ module.exports = defineConfig([{
promise,
jest,
github: fixupPluginRules(github),
joplin,
},
}, {
files: [
@@ -260,12 +257,6 @@ module.exports = defineConfig([{
rules: {
'no-console': 'off',
},
}, {
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.test.js', '**/*.test.jsx'],
rules: {
'joplin/describe-filename': ['error'],
},
}, {
// .jsx files should be linted with the default rules
files: ['**/*.jsx'],
+7 -6
View File
@@ -87,7 +87,7 @@
"eslint-plugin-promise": "7.3.0",
"eslint-plugin-react": "7.37.5",
"execa": "5.1.1",
"fs-extra": "11.3.5",
"fs-extra": "11.3.4",
"glob": "11.1.0",
"globals": "17.6.0",
"gulp": "4.0.2",
@@ -107,8 +107,8 @@
},
"packageManager": "yarn@4.16.0",
"resolutions": {
"@codemirror/view": "6.43.6",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.42.1",
"@codemirror/state": "6.6.0",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.5",
"@codemirror/autocomplete": "6.20.1",
@@ -121,6 +121,8 @@
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
"chokidar@^2.0.0": "3.5.3",
"rn-fetch-blob@0.12.0": "patch:rn-fetch-blob@npm%3A0.12.0#./.yarn/patches/rn-fetch-blob-npm-0.12.0-cf02e3c544.patch",
"app-builder-lib@26.0.0-alpha.7": "patch:app-builder-lib@npm%3A26.0.0-alpha.7#./.yarn/patches/app-builder-lib-npm-26.0.0-alpha.7-e1b3dca119.patch",
"app-builder-lib@24.13.3": "patch:app-builder-lib@npm%3A24.13.3#./.yarn/patches/app-builder-lib-npm-24.13.3-86a66c0bf3.patch",
"react-native-sqlite-storage@6.0.1": "patch:react-native-sqlite-storage@npm%3A6.0.1#./.yarn/patches/react-native-sqlite-storage-npm-6.0.1-8369d747bd.patch",
"react-native-paper@5.13.1": "patch:react-native-paper@npm%3A5.13.1#./.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch",
"react-native-popup-menu@0.17.0": "patch:react-native-popup-menu@npm%3A0.17.0#./.yarn/patches/react-native-popup-menu-npm-0.17.0-8b745d88dd.patch",
@@ -133,7 +135,7 @@
"node-gyp@npm:^9.0.0": "11.2.0",
"node-forge@npm:^1": "1.3.2",
"node-forge@npm:^1.3.0": "1.3.2",
"moment@npm:^2.8.4": "2.30.1",
"moment@npm:^2.8.4": "2.29.4",
"depd@npm:^2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:~2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:~1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
@@ -145,7 +147,6 @@
"@react-native-vector-icons/fontawesome5@npm:12.3.0": "patch:@react-native-vector-icons/fontawesome5@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-fontawesome5-npm-12.3.0-a1ca46610f.patch",
"@react-native-vector-icons/ionicons@npm:12.3.0": "patch:@react-native-vector-icons/ionicons@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-ionicons-npm-12.3.0-9bd4746f3f.patch",
"@react-native-vector-icons/material-design-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-design-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-design-icons-npm-12.4.0-890f7f618b.patch",
"@react-native-vector-icons/material-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-icons-npm-12.4.0-94138e627b.patch",
"app-builder-lib@npm:26.15.6": "patch:app-builder-lib@npm%3A26.15.6#~/.yarn/patches/app-builder-lib-npm-26.15.6-010811df92.patch"
"@react-native-vector-icons/material-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-icons-npm-12.4.0-94138e627b.patch"
}
}
+1 -4
View File
@@ -1,7 +1,5 @@
import { _ } from '@joplin/lib/locale';
import { reg } from '@joplin/lib/registry.js';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stdout can be called with formatted strings or arbitrary values
type StdoutFn = (text: any)=> void;
@@ -20,9 +18,8 @@ export default class BaseCommand {
throw new Error('Usage not defined');
}
public encryptionCheck(item: { encryption_applied?: number; is_locked?: number } | null) {
public encryptionCheck(item: { encryption_applied?: number } | null) {
if (item && item.encryption_applied) throw new Error(_('Cannot change encrypted item'));
if (isNoteLockEnabled() && NoteLockNote.isLocked(item)) throw new Error(_('Cannot change a locked note'));
}
public description(): string {
-26
View File
@@ -1,26 +0,0 @@
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-cat');
describe('command-cat', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
});
it('should refuse to display a locked note', async () => {
Setting.setValue('featureFlag.noteLock', true);
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
let output = '';
const command = setupCommandForTesting(Command, (text: string) => { output += text; });
await expect(command.action({ note: note.id, options: {} })).rejects.toThrow('locked note');
expect(output).toBe('');
});
});
-3
View File
@@ -4,8 +4,6 @@ import { _ } from '@joplin/lib/locale';
import { ModelType } from '@joplin/lib/BaseModel';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
class Command extends BaseCommand {
public override usage() {
@@ -25,7 +23,6 @@ class Command extends BaseCommand {
const item = await app().loadItem(ModelType.Note, title, { parent: app().currentFolder() });
if (!item) throw new Error(_('Cannot find "%s".', title));
if (isNoteLockEnabled() && NoteLockNote.isLocked(item)) throw new Error(_('Cannot display a locked note'));
let content = '';
-29
View File
@@ -1,29 +0,0 @@
import * as fs from 'fs-extra';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-edit');
describe('command-edit', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
});
it('should refuse to edit a locked note without writing a temp file', async () => {
Setting.setValue('featureFlag.noteLock', true);
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
const tempDir = Setting.value('tempDir');
await fs.ensureDir(tempDir);
const filesBefore = await fs.readdir(tempDir);
const command = setupCommandForTesting(Command);
await expect(command.action({ note: note.id })).rejects.toThrow('locked note');
expect(await fs.readdir(tempDir)).toEqual(filesBefore);
});
});
-44
View File
@@ -1,44 +0,0 @@
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
const Command = require('./command-set');
describe('command-set', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
});
// This guard lives in BaseCommand.encryptionCheck, so it also covers attach, done, edit, ren and todo.
it.each([
{ label: 'refuse to change a locked note', flagEnabled: true, expectedError: 'Cannot change a locked note', expectedTitle: 'hello' },
{ label: 'change a locked note when note lock is disabled', flagEnabled: false, expectedError: null, expectedTitle: 'renamed' },
])('should $label', async ({ flagEnabled, expectedError, expectedTitle }) => {
Setting.setValue('featureFlag.noteLock', flagEnabled);
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
const command = setupCommandForTesting(Command);
let error: string|null = null;
try {
await command.action({ note: note.id, name: 'title', value: 'renamed' });
} catch (e) {
error = (e as Error).message;
}
expect(error).toBe(expectedError);
expect((await Note.load(note.id)).title).toBe(expectedTitle);
});
it('should block changing the lock state of a plain note', async () => {
Setting.setValue('featureFlag.noteLock', true);
const note = await Note.save({ title: 'hello', body: 'plain', parent_id: '' });
const command = setupCommandForTesting(Command);
await expect(command.action({ note: note.id, name: 'is_locked', value: '1' })).rejects.toThrow('The note lock state cannot be changed from the command line');
expect((await Note.load(note.id)).is_locked).toBe(0);
});
});
-6
View File
@@ -4,7 +4,6 @@ import { _ } from '@joplin/lib/locale';
import { ModelType } from '@joplin/lib/BaseModel';
import Database from '@joplin/lib/database';
import Note from '@joplin/lib/models/Note';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
class Command extends BaseCommand {
public override usage() {
@@ -35,11 +34,6 @@ class Command extends BaseCommand {
for (let i = 0; i < notes.length; i++) {
this.encryptionCheck(notes[i]);
// Enabling the lock here would only fail deep inside the gated save, so refuse it outright.
if (isNoteLockEnabled() && propName === 'is_locked') {
throw new Error(_('The note lock state cannot be changed from the command line'));
}
const timestamp = Date.now();
const newNote: Record<string, unknown> = {
+1 -1
View File
@@ -48,7 +48,7 @@
"chalk": "4.1.2",
"compare-version": "0.1.2",
"file-type": "16.5.4",
"fs-extra": "11.3.5",
"fs-extra": "11.3.4",
"html-entities": "1.4.0",
"keytar": "7.9.0",
"md5": "2.3.0",
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
<en-export export-date="20260805T200517Z" application="Evernote" version="11.28.2">
<note>
<title>Note 2</title>
<created>20260805T185318Z</created>
<updated>20260805T200333Z</updated>
<note-attributes>
<author>author</author>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>This is a test.</div><div><br/></div><div><span style="--en-highlight:red;background-color: #ffe2d5;">Link to a note in another notebook</span>: <a type="inline-richlink" rev="{&quot;isCached&quot;:false}" href="https://share.evernote.com/note/3784df9c-5e9f-dd33-c246-a6e9616db99b">Test</a></div><div>Link to a note in the same notebook: <a type="inline-richlink" rev="{&quot;isCached&quot;:false}" href="https://share.evernote.com/note/471e9dd8-9436-276a-0594-6d3f5a006084">Example note</a></div></en-note> ]]>
</content>
</note>
<note>
<title>Example note</title>
<created>20260805T200313Z</created>
<updated>20260805T200320Z</updated>
<note-attributes>
<author>author</author>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>Testing!</div></en-note> ]]>
</content>
</note>
</en-export>
@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
<en-export export-date="20260805T200508Z" application="Evernote" version="11.28.2">
<note>
<title>Test</title>
<created>20260805T184526Z</created>
<updated>20260805T200439Z</updated>
<note-attributes>
<author>author</author>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div><br/></div><div><a type="inline-richlink" rev="{&quot;isCached&quot;:false}" href="evernote:///view/312260475/s710/0c61536b-4d58-e7ee-1070-7a1a50d94852/e312cdf9-8314-440f-91a6-4bfca42da635">Test note</a>. <a type="inline-richlink" rev="{&quot;isCached&quot;:false}" href="https://share.evernote.com/note/471e9dd8-9436-276a-0594-6d3f5a006084">Example note</a></div></en-note> ]]>
</content>
</note>
<note>
<title>Testing</title>
<created>20260805T185202Z</created>
<updated>20260805T200413Z</updated>
<note-attributes>
<author>author</author>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div><a rev="en_rl_none" href="https://share.evernote.com/note/3784df9c-5e9f-dd33-c246-a6e9616db99b">Test</a>...</div><div><br/></div><div><br/></div></en-note> ]]>
</content>
</note>
<note>
<title>Test note</title>
<created>20260805T184449Z</created>
<updated>20260805T184526Z</updated>
<note-attributes>
<author>author</author>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>This is a test note!</div><ul><li><div>Test</div></li></ul><div><br/></div><div><br/></div></en-note> ]]>
</content>
</note>
</en-export>
@@ -1 +1 @@
<a>test</a>
<a data-from-md href='#'>test</a>
+1 -2
View File
@@ -146,14 +146,13 @@ describe('app.reducer', () => {
toolCallId: 'call-1',
userDescription: '',
isError: false,
isEdit: true,
content: 'Result',
} satisfies ChatToolMessage),
});
expect(state.aiChatMessages).toMatchObject([
{ id: 'id-0', role: 'user', text: 'Test', raw: [{ role: 'user' }] },
{ id: 'id-1', role: 'assistant', text: 'Testing' },
{ id: 'id-1', role: 'assistant', text: 'Testing', editsApplied: 1, editsMissed: 0 },
]);
+9 -1
View File
@@ -13,7 +13,8 @@ export interface AiChatMessage {
id: string;
role: 'user' | 'assistant' | 'error' | 'separator';
text: string;
hide?: boolean;
editsApplied?: number;
editsMissed?: number;
// The raw message(s) corresponding to this event
raw: ChatMessage[];
@@ -316,8 +317,15 @@ export default function(state: AppState, action: any) {
state, action.windowId, 'aiChatMessages', messages => {
let lastMessage = messages[messages.length - 1];
if (lastMessage) {
const toolCall = action.toolCall;
const error = toolCall.isError;
const editsApplied = (lastMessage.editsApplied ?? 0) + (error ? 0 : 1);
const editsMissed = (lastMessage.editsMissed ?? 0) + (error ? 1 : 0);
lastMessage = {
...lastMessage,
editsApplied,
editsMissed,
raw: [
...lastMessage.raw,
action.toolCall,
-9
View File
@@ -772,15 +772,6 @@ class Application extends BaseApplication {
});
});
addTask('app/listen for note lock session events', () => {
eventManager.on(EventName.NoteLockSessionChange, (event) => {
this.dispatch({
type: 'SET_NOTE_LOCK_SESSION_UNLOCKED',
value: event.unlocked,
});
});
});
addTask('app/setupOcrService', () => this.setupOcrService());
return tasks;
@@ -3,7 +3,6 @@ import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
import Note from '@joplin/lib/models/Note';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import bridge from '../services/bridge';
export const declaration: CommandDeclaration = {
@@ -19,14 +18,11 @@ export const runtime = (): CommandRuntime => {
try {
const note = await Note.load(noteId);
// The enabled condition can be bypassed when the command is executed directly (e.g. via
// toggleExternalEditing): the external editor would get ciphertext, or write plaintext to disk.
if (isNoteLockEnabled() && note.is_locked) throw new Error(_('Locked notes cannot be opened in an external editor'));
void ExternalEditWatcher.instance().openAndWatch(note);
} catch (error) {
bridge().showErrorMessageBox(_('Error opening note in editor: %s', error.message));
}
},
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && !noteIsLocked',
enabledCondition: 'oneNoteSelected && !noteIsReadOnly',
};
};
@@ -23,7 +23,7 @@ export const runtime = (): CommandRuntime => {
void CommandService.instance().execute('startExternalEditing', noteId);
}
},
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && !noteIsLocked && (!modalDialogVisible || gotoAnythingVisible)',
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && (!modalDialogVisible || gotoAnythingVisible)',
mapStateToTitle: (state: AppState) => {
const noteId = stateUtils.selectedNoteId(state);
return state.watchedNoteFiles.includes(noteId) ? _('Stop') : '';
@@ -2,45 +2,17 @@ import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import { AiChatMessage } from '../../app.reducer';
import InlineMarkdownDisplay from '../InlineMarkdownDisplay';
import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/types';
import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types';
const toolResultSummary = (actions: ChatMessage[]) => {
const toolResults = actions.filter(action => action.role === ChatRole.Tool);
const editSummary = () => {
let applied = 0;
let missed = 0;
let lastEdit: ChatToolMessage|null = null;
for (const result of toolResults) {
if (!result.isEdit) continue;
if (result.isError) {
missed ++;
} else {
applied ++;
lastEdit = result;
}
}
if (applied + missed === 0) return '';
if (missed === 0 && applied > 1) return _('%d edit(s) applied.', applied);
if (missed === 0 && lastEdit) return lastEdit.userDescription;
return _('%d edit(s) applied, %d could not be placed automatically.', applied, missed);
};
return [
...toolResults
.filter(result => !result.isEdit)
.map(result => result.userDescription),
editSummary(),
]
.filter(item => !!item)
.join('\n');
};
const hasToolError = (actions: ChatMessage[]) => {
return actions.some(action => action.role === ChatRole.Tool && action.isError);
const editsSummary = (actions: ChatMessage[], applied: number, missed: number) => {
if (applied + missed === 0) return '';
if (missed === 0 && applied > 1) return _('%d edit(s) applied.', applied);
if (missed === 0) {
const toolResults = actions.filter(action => action.role === ChatRole.Tool);
return toolResults.map(result => result.userDescription).join('\n');
}
return _('%d edit(s) applied, %d could not be placed automatically.', applied, missed);
};
interface Props {
@@ -55,7 +27,7 @@ const ChatMessageItem: React.FC<Props> = ({ message }) => {
return <div className='error'>{message.text}</div>;
}
const summary = message.role === 'assistant' ? toolResultSummary(message.raw) : '';
const summary = message.role === 'assistant' ? editsSummary(message.raw, message.editsApplied ?? 0, message.editsMissed ?? 0) : '';
// Always show something in the message box:
const textContent = !message.text && !summary ? _('(no message)') : message.text;
@@ -72,7 +44,7 @@ const ChatMessageItem: React.FC<Props> = ({ message }) => {
{content}
{summary && (
<div className='meta'>
{hasToolError(message.raw)
{(message.editsMissed ?? 0) > 0
? <span className='warning'>{summary}</span>
: <span>{summary}</span>}
</div>
@@ -18,7 +18,6 @@ import JoplinError from '@joplin/lib/JoplinError';
import eventManager, { EventName, ItemChangeEvent } from '@joplin/lib/eventManager';
import { Second } from '@joplin/utils/time';
import ChatMessageItem from './ChatMessageItem';
import NavService from '@joplin/lib/services/NavService';
const logger = Logger.create('ChatPanel');
@@ -143,13 +142,11 @@ const ChatPanel: React.FC<Props> = (props) => {
lastNoteIdRef.current = props.noteId;
if (prev === null || prev === props.noteId || !props.noteId) return;
if (messagesLengthRef.current === 0) return;
const text = _('— now viewing: %s —', props.noteTitle || _('(untitled)'));
appendMessage({
id: makeId(),
role: 'separator',
text,
raw: [{ role: ChatRole.User, content: _('Switched notes: %s', text) }],
text: _('— now viewing: %s —', props.noteTitle || _('(untitled)')),
raw: [],
});
}, [props.noteId, props.noteTitle, appendMessage]);
@@ -191,7 +188,7 @@ const ChatPanel: React.FC<Props> = (props) => {
// Captured so we can roll it back on failure — otherwise a retry would
// send the prior user turn as history alongside the new prompt.
const userTurnId = makeId();
let hadVisibleResponse = false;
let hadSuccessfulResponse = false;
appendMessage({
id: userTurnId,
role: 'user',
@@ -220,8 +217,6 @@ const ChatPanel: React.FC<Props> = (props) => {
body: note.body,
title: note.title,
selection,
noteId: note.id,
folderId: note.parent_id,
};
};
@@ -241,16 +236,15 @@ const ChatPanel: React.FC<Props> = (props) => {
// System messages are not shown in the UI
if (entry.role === ChatRole.System) continue;
hadSuccessfulResponse = true;
if (entry.role === ChatRole.Tool) {
addToolResult(entry);
} else {
hadVisibleResponse ||= !entry.hide;
appendMessage({
id: makeId(),
role: entry.role,
text: entry.content,
hide: entry.hide,
raw: [entry],
});
}
@@ -301,7 +295,7 @@ const ChatPanel: React.FC<Props> = (props) => {
logger.warn('Chat failed:', error);
if (abortController.signal.aborted) return;
if (!hadVisibleResponse) {
if (!hadSuccessfulResponse) {
dispatch({ type: 'AI_CHAT_REMOVE', windowId, id: userTurnId });
setInput(text);
}
@@ -322,10 +316,6 @@ const ChatPanel: React.FC<Props> = (props) => {
dispatch({ type: 'AI_CHAT_RESET', windowId: windowId });
}, [dispatch, windowId, cancelRequest]);
const handleClose = useCallback(() => {
void CommandService.instance().executeInWindow('toggleAiChat', { windowId: windowId, args: [] });
}, [windowId]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Don't send while an IME composition is in flight — Enter commits
// the composition for CJK / accented input.
@@ -353,24 +343,15 @@ const ChatPanel: React.FC<Props> = (props) => {
return { content, showingMessages: false };
}
const visibleMessages = messages.filter(m => !m.hide);
const content = <>
{props.aiDegraded && <AiDegradedNotice className='degraded-status' />}
<div className='messages' aria-live={hasFocus ? 'polite' : undefined}>
{visibleMessages.length === 0 && (
{messages.length === 0 && (
<div className='empty'>
{_('Ask about this note, or request changes. Select text in the editor first to scope the request to that selection.')}
<br/>
<br/>
<button
type='button'
className='link-button'
onClick={() => NavService.go('Config', { props: { defaultSection: 'ai.tools' } })}
>{_('Manage capabilities')}</button>
</div>
)}
{visibleMessages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
{messages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
<div ref={messagesEndRef} />
</div>
<div className='composer'>
@@ -404,28 +385,13 @@ const ChatPanel: React.FC<Props> = (props) => {
</div>
</>;
return { content, showingMessages: visibleMessages.length > 0 };
return { content, showingMessages: messages.length > 0 };
};
const sendButtonLabel = sending ? _('Stop generating') : _('Send');
const headerId = useId();
const { content, showingMessages } = renderContent();
const renderHeaderActions = () => {
if (showingMessages) {
return <button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>;
}
const closeLabel = _('Close');
return <button
type='button'
className='close'
onClick={handleClose}
title={closeLabel}
aria-label={closeLabel}
><i className='fas fa-times' role='img' aria-hidden={true}/></button>;
};
return (
<div
className='chat-panel'
@@ -434,9 +400,11 @@ const ChatPanel: React.FC<Props> = (props) => {
onFocus={onFocus}
onBlur={onBlur}
>
<div className='header chat-panel-header'>
<div className='header'>
<h1 className='title' id={headerId}>{_('AI Chat')}</h1>
{renderHeaderActions()}
{showingMessages && (
<button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>
)}
</div>
{content}
</div>
+15 -17
View File
@@ -19,25 +19,23 @@
font-weight: 600;
}
.chat-panel-header {
> .title {
flex: 1;
font-size: inherit;
margin: 0;
}
.chat-panel > .header > .title {
flex: 1;
font-size: inherit;
margin: 0;
}
> .reset, > .close {
background: none;
border: none;
color: var(--joplin-color-faded);
cursor: pointer;
font-size: 0.85em;
padding: 2px 6px;
.chat-panel > .header > .reset {
background: none;
border: none;
color: var(--joplin-color-faded);
cursor: pointer;
font-size: 0.85em;
padding: 2px 6px;
}
&:hover {
color: var(--joplin-color);
}
}
.chat-panel > .header > .reset:hover {
color: var(--joplin-color);
}
.chat-panel > .degraded-status {
@@ -7,7 +7,7 @@ import shim, { MessageBoxType } from '@joplin/lib/shim';
import dialogs from '../dialogs';
import { decryptedStatText, determineKeyPassword, dontReencryptData, enableEncryptionConfirmationMessages, onSavePasswordClick, onToggleEnabledClick, reencryptData, upgradeMasterKey, useInputPasswords, useNeedMasterPassword, usePasswordChecker, useStats, useToggleShowDisabledMasterKeys } from '@joplin/lib/components/EncryptionConfigScreen/utils';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import { getEncryptionEnabled, localSyncInfoSelector, masterKeyEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { getDefaultMasterKey, getMasterPasswordStatusMessage, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
import Button, { ButtonLevel } from '../Button/Button';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
@@ -606,7 +606,7 @@ export const EncryptionConfigScreen = (props: Props) => {
};
const mapStateToProps = (state: AppState) => {
const syncInfo = localSyncInfoSelector(state);
const syncInfo = new SyncInfo(state.settings['syncInfoCache']);
return {
themeId: state.settings.theme,
+9 -12
View File
@@ -27,8 +27,7 @@ import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
import removeKeylessItems from './ResizableLayout/utils/removeKeylessItems';
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import executeCallbackUrl from './MainScreen/handleCallbackUrl';
import { isCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import ElectronAppWrapper from '../ElectronAppWrapper';
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
@@ -39,6 +38,7 @@ import TrashNotification from './TrashNotification/TrashNotification';
import UpdateNotification from './UpdateNotification/UpdateNotification';
import PluginNotification from './PluginNotification/PluginNotification';
import { Toast } from '@joplin/lib/services/plugins/api/types';
import QuitSyncDialog from './QuitSyncDialog';
import Logger from '@joplin/utils/Logger';
import checkForUpdates, { isReleaseVersion } from '../checkForUpdates';
@@ -153,23 +153,20 @@ class MainScreenComponent extends React.Component<Props, State> {
ipcRenderer.on('asynchronous-message', (_event: import('electron').IpcRendererEvent, message: string, args: { url: string }) => {
if (message === 'openCallbackUrl') {
void this.openCallbackUrl(args.url);
this.openCallbackUrl(args.url);
}
});
const initialCallbackUrl = (bridge().electronApp() as ElectronAppWrapper).initialCallbackUrl();
if (initialCallbackUrl) {
void this.openCallbackUrl(initialCallbackUrl);
this.openCallbackUrl(initialCallbackUrl);
}
}
private async openCallbackUrl(url: string) {
try {
if (!isCallbackUrl(url)) throw new Error(`Invalid callback URL: ${url}`);
await executeCallbackUrl(url);
} catch (error) {
logger.error('Error handling callback URL:', error);
}
private openCallbackUrl(url: string) {
if (!isCallbackUrl(url)) throw new Error(`Invalid callback URL: ${url}`);
const { command, params } = parseCallbackUrl(url);
void CommandService.instance().execute(command.toString(), params.id);
}
private updateLayoutPluginViews(layout: LayoutItem, plugins: PluginStates) {
@@ -185,7 +182,6 @@ class MainScreenComponent extends React.Component<Props, State> {
if (!existingItem) {
draftLayout.children.push({
key: viewId,
visible: info.view.opened,
context: {
pluginId: info.plugin.id,
},
@@ -802,6 +798,7 @@ class MainScreenComponent extends React.Component<Props, State> {
themeId={this.props.themeId}
toast={this.props.toast}
/>
<QuitSyncDialog themeId={this.props.themeId} />
{messageComp}
{layoutComp}
</div>
@@ -1,67 +0,0 @@
import { utils as commandUtils } from '@joplin/lib/services/CommandService';
import executeCallbackUrl from './handleCallbackUrl';
import { withWarningSilenced } from '@joplin/lib/testing/test-utils';
const mockOpenExternal = jest.fn();
jest.mock('../../services/bridge', () => ({
__esModule: true,
default: () => ({
openExternal: (url: string) => mockOpenExternal(url),
}),
}));
const setSelectedNoteId = (noteId: string | null) => {
// executeCallbackUrl reads the selected note id via commandUtils.store; a
// null id makes handleGetCurrentNote take its x-error branch, which routes
// the caller-supplied target through respond() without touching the DB.
commandUtils.store = {
getState: () => ({ selectedNoteIds: noteId ? [noteId] : [] }),
};
};
describe('handleCallbackUrl', () => {
beforeEach(() => {
mockOpenExternal.mockClear();
setSelectedNoteId(null);
});
test.each([
'x-custom-scheme://arbitrary-scheme-reached',
'file:///etc/passwd',
'ms-msdt://something',
'mailto:someone@example.com',
'not a url',
])('should not dispatch a callback target with a disallowed scheme (%s)', async (target) => {
await withWarningSilenced(/Rejected malformed callback|Rejected callback target with disallowed scheme/, async () => {
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
}, { requireWarning: true });
expect(mockOpenExternal).not.toHaveBeenCalled();
});
test.each([
'http://example.com/cb',
'https://example.com/cb',
])('should dispatch a callback target with an allowed scheme (%s)', async (target) => {
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
expect(mockOpenExternal.mock.calls[0][0]).toContain(`${target}?`);
});
it('should not leak the raw error message on the error path', async () => {
// Force a throw inside the try block so the catch path builds the response.
commandUtils.store = {
getState: () => { throw new Error('secret path /home/victim/.config/joplin/database.sqlite'); },
};
await withWarningSilenced(/Error handling callback URL command "getCurrentNote":.*secret path \/home\/victim/, async () => {
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent('https://example.com/cb')}`);
}, { requireWarning: true });
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
const responseUrl = mockOpenExternal.mock.calls[0][0];
expect(responseUrl).not.toContain('secret path');
expect(responseUrl).not.toContain('database.sqlite');
expect(new URL(responseUrl).searchParams.get('errorMessage')).toBe('The command could not be completed');
});
});
@@ -1,123 +0,0 @@
import { CallbackUrlCommand, getNoteCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import CommandService, { utils as commandUtils } from '@joplin/lib/services/CommandService';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import { stateUtils } from '@joplin/lib/reducer';
import { AppState } from '../../app.reducer';
import bridge from '../../services/bridge';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('handleCallbackUrl');
// Opens the caller's x-success / x-error URL with the result appended as query params.
const respond = (target: string, params: Record<string, string> = {}) => {
if (!target) return;
// The callback target is untrusted input from the x-callback-url. Anything
// that is not file: is handed straight to shell.openExternal(), i.e. the OS
// URI dispatcher, so restrict it to schemes we're willing to invoke. This
// also covers the x-error path below, which fires on any thrown exception.
let protocol;
try {
protocol = new URL(target).protocol;
} catch (error) {
// Don't log the caller-supplied target: it's untrusted and may contain
// secrets in its query or fragment.
logger.warn('Rejected malformed callback target');
return;
}
if (protocol !== 'http:' && protocol !== 'https:') {
logger.warn(`Rejected callback target with disallowed scheme "${protocol}"`);
return;
}
const query = Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
const fragmentIndex = target.indexOf('#');
const base = fragmentIndex === -1 ? target : target.substring(0, fragmentIndex);
const fragment = fragmentIndex === -1 ? '' : target.substring(fragmentIndex);
const separator = base.includes('?') ? '&' : '?';
const responseUrl = query ? `${base}${separator}${query}${fragment}` : target;
void bridge().openExternal(responseUrl);
};
const handleGetCurrentNote = async (params: Record<string, string>) => {
const state = commandUtils.store.getState() as AppState;
const noteId = stateUtils.selectedNoteId(state);
if (!noteId) {
respond(params['x-error'], { errorMessage: 'No note is currently selected' });
return;
}
const note = await Note.load(noteId);
if (!note) {
respond(params['x-error'], { errorMessage: 'The selected note could not be loaded' });
return;
}
respond(params['x-success'], {
title: note.title,
url: getNoteCallbackUrl(note.id),
});
};
const handleCreateNote = async (params: Record<string, string>) => {
const folder = await Folder.getValidActiveFolder();
if (!folder) {
respond(params['x-error'], { errorMessage: 'No valid notebook is available to create the note in' });
return;
}
// Not provisional: an externally-requested note should persist even if unedited.
const note = await Note.save({
...Note.previewFieldsWithDefaultValues({ includeTimestamps: false }),
title: params.title ?? '',
body: params.body ?? '',
parent_id: folder.id,
});
commandUtils.store.dispatch({ type: 'NOTE_SELECT', id: note.id });
respond(params['x-success'], {
title: note.title,
url: getNoteCallbackUrl(note.id),
});
};
// Verbs dispatched directly to CommandService by name. Kept as an explicit
// allowlist (not a catch-all) so a crafted URL can't run arbitrary commands. See 69826610a.
const openCommands: string[] = [
CallbackUrlCommand.OpenNote,
CallbackUrlCommand.OpenFolder,
CallbackUrlCommand.OpenTag,
];
const executeCallbackUrl = async (url: string) => {
const info = parseCallbackUrl(url);
if (!openCommands.includes(info.command) &&
info.command !== CallbackUrlCommand.GetCurrentNote &&
info.command !== CallbackUrlCommand.CreateNote) {
throw new Error(`Unhandled callback URL command: ${info.command}`);
}
try {
if (info.command === CallbackUrlCommand.GetCurrentNote) {
await handleGetCurrentNote(info.params);
} else if (info.command === CallbackUrlCommand.CreateNote) {
await handleCreateNote(info.params);
} else {
await CommandService.instance().execute(info.command.toString(), info.params.id);
logger.info(`Executed callback URL command: ${info.command}`);
}
} catch (error) {
logger.error(`Error handling callback URL command "${info.command}":`, error);
// Return an opaque status rather than error.message, which can leak
// profile paths and item ids to the caller-supplied x-error target.
respond(info.params['x-error'], { errorMessage: 'The command could not be completed' });
}
};
export default executeCallbackUrl;
+1 -7
View File
@@ -13,7 +13,6 @@ import versionInfo, { PackageInfo } from '@joplin/lib/versionInfo';
import { ImportModule } from '@joplin/lib/services/interop/Module';
import InteropServiceHelper from '../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import { isContextMenuItemLocation, MenuItem, MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import menuCommandNames from './menuCommandNames';
@@ -865,15 +864,10 @@ function useMenu(props: Props) {
menuItemDic.toggleExternalEditing,
separator(),
menuItemDic.linkToNote,
menuItemDic.copyNoteExternalLink,
menuItemDic.setTags,
menuItemDic.showShareNoteDialog,
menuItemDic.convertNoteToMarkdown,
...(isNoteLockEnabled() ? [
separator(),
menuItemDic.enableNoteEncryption,
menuItemDic.disableNoteEncryption,
menuItemDic.lockEncryptedNotes,
] : []),
separator(),
menuItemDic.showNoteProperties,
menuItemDic.showNoteContentProperties,
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
import { HighlightedWord } from '@joplin/lib/reducer';
const logger = Logger.create('useEditorSearch');
@@ -15,8 +14,9 @@ interface SetMarkersOptions {
showEditorMarkers?: boolean;
withSelection?: boolean;
}
type Keyword = { value: string };
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: HighlightedWord[], options: SetMarkersOptions)=> number;
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: Keyword[], options: SetMarkersOptions)=> number;
// Modified from codemirror/addons/search/search.js
@@ -109,11 +109,11 @@ export default function useEditorSearchExtension() {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
function escapeRegExp(keyword: string) {
return keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
return keyword.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
function getSearchTerm(keyword: HighlightedWord) {
const value = escapeRegExp(typeof keyword === 'string' ? keyword : keyword.value);
function getSearchTerm(keyword: Keyword) {
const value = escapeRegExp(keyword.value);
return new RegExp(value, 'gi');
}
@@ -139,8 +139,6 @@ export default function useEditorSearchExtension() {
clearMarkers();
const keywordValue = (keyword: HighlightedWord) => typeof keyword === 'string' ? keyword : keyword.value;
// HIGHLIGHT KEYWORDS
// When doing a global search it's possible to have multiple keywords
// This means we need to highlight each one
@@ -148,12 +146,12 @@ export default function useEditorSearchExtension() {
for (let i = 0; i < keywords.length; i++) {
const keyword = keywords[i];
if (keywordValue(keyword) === '') continue;
if (keyword.value === '') continue;
const searchTerm = getSearchTerm(keyword);
// We only want to scroll the first keyword into view in the case of a multi keyword search
const scrollTo = i === 0 && (previousKeywordValue !== keywordValue(keyword) || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
try {
const match = highlightSearch(cm, searchTerm, options.selectedIndex, scrollTo, !!options.withSelection);
@@ -174,16 +172,15 @@ export default function useEditorSearchExtension() {
setPreviousSearchTimestamp(options.searchTimestamp);
// SEARCHOVERLAY
// We only want to highlight all matches when there is only 1 main search term
const mainSearch = keywords.filter(keyword => typeof keyword === 'string' || keyword.source !== 'semantic');
if (mainSearch.length !== 1 || keywordValue(mainSearch[0]) === '') {
// We only want to highlight all matches when there is only 1 search term
if (keywords.length !== 1 || keywords[0].value === '') {
clearOverlay(cm);
const prev = keywords.length > 1 ? keywordValue(keywords[0]) : '';
const prev = keywords.length > 1 ? keywords[0].value : '';
setPreviousKeywordValue(prev);
return 0;
}
const searchTerm = getSearchTerm(mainSearch[0]);
const searchTerm = getSearchTerm(keywords[0]);
// Determine the number of matches in the source, this is passed on
// to the NoteEditor component
@@ -192,10 +189,10 @@ export default function useEditorSearchExtension() {
// Don't bother clearing and re-calculating the overlay if the search term
// hasn't changed
if (keywordValue(keywords[0]) === previousKeywordValue) return nMatches;
if (keywords[0].value === previousKeywordValue) return nMatches;
clearOverlay(cm);
setPreviousKeywordValue(keywordValue(keywords[0]));
setPreviousKeywordValue(keywords[0].value);
// These operations are pretty slow, so we won't add use them until the user
// has finished typing, 500ms is probably enough time
@@ -710,14 +710,6 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [renderedBody, webviewReady]);
// Stop media playing in the viewer once it's hidden - https://github.com/laurent22/joplin/issues/15278
const viewerVisible = props.visiblePanes.includes('viewer');
useEffect(() => {
if (!viewerVisible) {
webviewRef.current?.stopMediaPlayback();
}
}, [viewerVisible]);
const { onSetInitialMarkersRef } = useEditorSearchHandler({
setLocalSearchResultCount: props.setLocalSearchResultCount,
searchMarkers: props.searchMarkers,
@@ -297,14 +297,6 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
useRefocusOnVisiblePaneChange({ editorRef, webviewRef, visiblePanes: props.visiblePanes });
// Stop media playing in the viewer once it's hidden - https://github.com/laurent22/joplin/issues/15278
const viewerVisible = props.visiblePanes.includes('viewer');
useEffect(() => {
if (!viewerVisible) {
webviewRef.current?.stopMediaPlayback();
}
}, [viewerVisible]);
useEditorSearchHandler({
setLocalSearchResultCount: props.setLocalSearchResultCount,
searchMarkers: props.searchMarkers,
@@ -109,11 +109,7 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
}
const searchState = editor.getSearchState();
const externalSearchText = props.externalSearch
.keywords
.filter(k => typeof k === 'string' || k.source !== 'semantic')
.map(k => typeof k === 'string' ? k : k.value)
.join(' ') || searchState.searchText;
const externalSearchText = props.externalSearch.keywords.map(k => k.value).join(' ') || searchState.searchText;
if (externalSearchText === searchState.searchText && searchState.dialogVisible === props.useLocalSearch) {
return;
@@ -25,17 +25,4 @@ export default () => ({
'replaced $ matches': _('replaced $ matches'),
'replaced match on line $': _('replaced match on line $'),
'on line': _('on line'),
// Table editor context menu. These keys must be kept in sync with the
// state.phrase() calls in packages/editor renderTables.ts.
'Insert row above': _('Insert row above'),
'Insert row below': _('Insert row below'),
'Insert column left': _('Insert column left'),
'Insert column right': _('Insert column right'),
'Move row up': _('Move row up'),
'Move row down': _('Move row down'),
'Move column left': _('Move column left'),
'Move column right': _('Move column right'),
'Delete row': _('Delete row'),
'Delete column': _('Delete column'),
});
@@ -33,8 +33,7 @@ import { DropHandler } from '../../utils/useDropHandler';
import Logger from '@joplin/utils/Logger';
import useWebViewApi from './utils/useWebViewApi';
import useLinkTooltips from './utils/useLinkTooltips';
import { blur, focus } from '@joplin/lib/utils/focusHandler';
import useHighlightedSearchTerms from './utils/useHighlightedSearchTerms';
import { focus } from '@joplin/lib/utils/focusHandler';
const md5 = require('md5');
import { clipboard } from 'electron';
const supportedLocales = require('./supportedLocales');
@@ -187,9 +186,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
if (!editorRef.current) return '';
return prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editorRef.current.getContent(), props.contentOriginalCss);
},
blurEditor: () => {
if (editor) blur('TinyMCE::reload', editor.getBody());
},
resetScroll: () => {
if (editor) editor.getWin().scrollTo(0, 0);
},
@@ -1203,8 +1199,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
};
}, [editor, onEditorContentClick]);
useHighlightedSearchTerms(editor, props.searchMarkers.keywords, props.themeId);
// This is to handle dropping notes on the editor. In this case, we add an
// overlay over the editor, which makes it a valid drop target. This in
// turn makes NoteEditor get the drop event and dispatch it.
@@ -1249,8 +1243,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// https://github.com/facebook/react/issues/14010#issuecomment-433788147
const props_onChangeRef = useRef<NoteBodyEditorProps['onChange']>(null);
props_onChangeRef.current = props.onChange;
const editorNoteReloadTimeRequestRef = useRef(props.editorNoteReloadTimeRequest);
editorNoteReloadTimeRequestRef.current = props.editorNoteReloadTimeRequest;
const prop_htmlToMarkdownRef = useRef<HtmlToMarkdownHandler>(null);
prop_htmlToMarkdownRef.current = props.htmlToMarkdown;
@@ -1263,14 +1255,12 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
if (!info) return;
nextOnChangeEventInfo.current = null;
if (info.editorNoteReloadTimeRequest !== editorNoteReloadTimeRequestRef.current) return;
resetLinkTooltips();
const contentMd = await prop_htmlToMarkdownRef.current(info.contentMarkupLanguage, info.editor.getContent(), info.contentOriginalCss);
lastOnChangeEventInfo.current.content = contentMd;
lastOnChangeEventInfo.current.resourceInfos = await attachedResources(contentMd);
if (info.editorNoteReloadTimeRequest !== editorNoteReloadTimeRequestRef.current) return;
props_onChangeRef.current({
changeId: info.changeId,
@@ -1311,7 +1301,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor: editor,
contentMarkupLanguage: props.contentMarkupLanguage,
contentOriginalCss: props.contentOriginalCss,
editorNoteReloadTimeRequest: editorNoteReloadTimeRequestRef.current,
};
onChangeHandlerTimeoutRef.current = shim.setTimeout(async () => {
@@ -1558,8 +1547,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor.on(TinyMceEditorEvents.JoplinChange, onChangeHandler);
editor.on(TinyMceEditorEvents.Undo, onChangeHandler);
editor.on(TinyMceEditorEvents.Redo, onChangeHandler);
editor.on(TinyMceEditorEvents.FormatApply, onChangeHandler);
editor.on(TinyMceEditorEvents.FormatRemove, onChangeHandler);
editor.on(TinyMceEditorEvents.ExecCommand, onExecCommand);
editor.on(TinyMceEditorEvents.SetAttrib, onSetAttrib);
editor.on('TableModified', onTableModified);
@@ -1577,8 +1564,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor.off(TinyMceEditorEvents.JoplinChange, onChangeHandler);
editor.off(TinyMceEditorEvents.Undo, onChangeHandler);
editor.off(TinyMceEditorEvents.Redo, onChangeHandler);
editor.off(TinyMceEditorEvents.FormatApply, onChangeHandler);
editor.off(TinyMceEditorEvents.FormatRemove, onChangeHandler);
editor.off(TinyMceEditorEvents.ExecCommand, onExecCommand);
editor.off(TinyMceEditorEvents.SetAttrib, onSetAttrib);
editor.off('TableModified', onTableModified);
@@ -15,8 +15,6 @@ export enum TinyMceEditorEvents {
Redo = 'Redo',
ExecCommand = 'ExecCommand',
SetAttrib = 'SetAttrib',
FormatApply = 'FormatApply',
FormatRemove = 'FormatRemove',
}
export type DispatchDidUpdateCallback = (editor: Editor)=> void;
@@ -1,224 +0,0 @@
import { HighlightedWord } from '@joplin/lib/reducer';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useEffect, useMemo } from 'react';
import { Editor, EditorEvent } from 'tinymce';
declare global {
interface Window {
Highlight: typeof Highlight;
Range: typeof Range;
CSS: typeof CSS;
}
}
const useSearchRegexes = (searchTerms: HighlightedWord[]) => {
return useMemo(() => {
return searchTerms.map((term: HighlightedWord) => {
let text;
if (typeof term === 'string') {
text = term;
} else {
text = term.value;
}
// Only highlight short matches at word boundaries (roughly matching the note viewer
// behavior)
const regexText = SearchEngine.instance().queryTermToRegex(text, { anchorToWordBoundary: text.length <= 2 });
return regexText ? new RegExp(regexText, 'ig') : null;
}).filter(regex => !!regex);
}, [searchTerms]);
};
const searchHighlightKey = 'jop-search-highlight';
const useHighlightStyleSheet = (editor: Editor, themeId: number) => {
useEffect(() => {
if (!editor) {
return () => {};
}
const theme: Theme = themeStyle(themeId);
const style = editor.dom.create('style', {}, `
/* Avoid showing highlights when the user has the find dialog open and
there are TinyMCE-provided search results */
body:not(:has(span.mce-match-marker)) {
::highlight(${searchHighlightKey}) {
background-color: ${theme.searchMarkerBackgroundColor};
color: ${theme.searchMarkerColor};
}
}
`);
editor.getDoc().head.appendChild(style);
return () => {
style.remove();
};
}, [editor, themeId]);
};
const useHighlighter = (editor: Editor, searchRegexes: RegExp[]) => {
return useMemo(() => {
if (!editor) {
return { canHighlight: false };
}
const editorWindow = editor.getWin();
const ranges: Map<Node, Range[]> = new Map();
let highlight: Highlight = undefined;
const processNode = (node: Node) => {
ranges.delete(node);
type FoundChildAndOffset = { node: Node; offset: number };
const findChildAtTextOffset = (parent: Node, offset: number): FoundChildAndOffset => {
if (offset > parent.textContent.length) return null;
if (parent.nodeName === '#text') {
return { node: parent, offset };
}
let start = 0;
for (const child of parent.childNodes) {
if (child.nodeName === '#comment') continue;
const found = findChildAtTextOffset(child, offset - start);
start += child.textContent.length;
if (found) return found;
}
return null;
};
const buildRange = (parent: Node, startIndex: number, endIndex: number) => {
const range: Range = new editorWindow.Range();
const start = findChildAtTextOffset(parent, startIndex);
const end = findChildAtTextOffset(parent, endIndex);
range.setStart(start?.node ?? parent, start?.offset ?? 0);
range.setEnd(end?.node ?? parent, end?.offset ?? parent.textContent.length);
return range;
};
// Process highlights at the paragraph level, where possible, to pick up formatting that crosses Markdown boundaries
const isLeaf = node.nodeName === '#text' || node.nodeName === 'P';
if (isLeaf) {
const childRanges = [];
for (const term of searchRegexes) {
const matches = node.textContent.matchAll(term);
for (const match of matches) {
const startIndex = match.index ?? 0;
const endIndex = (match.index ?? 0) + match[0].length;
if (startIndex !== endIndex) {
childRanges.push(buildRange(node, startIndex, endIndex));
}
}
}
if (childRanges.length > 0) {
ranges.set(node, childRanges);
}
} else {
for (const child of node.childNodes) {
processNode(child);
}
}
};
const closestParagraph = (node: Node) => node.parentElement?.closest('p');
const onNodeChanged = (node: Node) => {
highlight?.clear();
processNode(closestParagraph(node) ?? node);
highlight = new editorWindow.Highlight(...[...ranges.values()].flat());
editorWindow.CSS.highlights.set(searchHighlightKey, highlight);
};
const onNodeRemoved = (node: Node, parent: Node) => {
const removeFromRanges = (node: Node) => {
ranges.delete(node);
for (const child of node.childNodes) {
removeFromRanges(child);
}
};
removeFromRanges(node);
// Handle the case where removing a node created a match:
onNodeChanged(closestParagraph(parent) ?? parent);
};
const clearHighlights = () => {
highlight?.clear();
editorWindow.CSS.highlights.delete(searchHighlightKey);
ranges.clear();
};
return { onNodeChanged, onNodeRemoved, clearHighlights, canHighlight: searchRegexes.length > 0 };
}, [editor, searchRegexes]);
};
const useHighlightedSearchTerms = (editor: Editor, searchTerms: HighlightedWord[], themeId: number) => {
const searchRegexes = useSearchRegexes(searchTerms);
useHighlightStyleSheet(editor, themeId);
const highlighter = useHighlighter(editor, searchRegexes);
useEffect(() => {
if (!editor || !highlighter.canHighlight) {
return () => {};
}
const editorBody = editor.getBody();
type NodeChangeEvent = { element: Element };
const onNodeChange = ({ element }: EditorEvent<NodeChangeEvent>) => {
highlighter.onNodeChanged(element);
};
const onKeyUp = (_event: KeyboardEvent) => {
// Use selectedNode and not event.target -- event.target seems to always point
// to the body.
const selectedNode = editor.selection.getNode();
if (selectedNode) {
highlighter.onNodeChanged(selectedNode);
}
};
const onSetContent = () => {
highlighter.onNodeChanged(editorBody);
};
editor.on('NodeChange', onNodeChange);
editor.on('SetContent', onSetContent);
// NodeChange doesn't fire while typing, so we also need keyup
editor.on('keyup', onKeyUp);
// NodeChange also doesn't fire for certain DOM changes (e.g. when TinyMCE-provided search highlights are added/removed):
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
highlighter.onNodeRemoved(node, mutation.target);
}
for (const node of mutation.addedNodes) {
highlighter.onNodeChanged(node);
}
}
});
observer.observe(editorBody, { subtree: true, childList: true });
onSetContent();
return () => {
observer.disconnect();
highlighter.clearHighlights();
editor.off('NodeChange', onNodeChange);
editor.off('keyup', onKeyUp);
editor.off('SetContent', onSetContent);
};
}, [editor, highlighter]);
};
export default useHighlightedSearchTerms;
@@ -6,9 +6,7 @@ import shim from '@joplin/lib/shim';
const useLinkTooltips = (editor: Editor|null) => {
const resetModifiedTitles = useCallback(() => {
if (!editor) return;
const doc = editor.getDoc();
if (!doc) return;
for (const element of doc.querySelectorAll('a[data-joplin-original-title]')) {
for (const element of editor.getDoc().querySelectorAll('a[data-joplin-original-title]')) {
element.setAttribute('title', element.getAttribute('data-joplin-original-title') ?? '');
element.removeAttribute('data-joplin-original-title');
}
@@ -20,7 +20,6 @@ import { WhiteboardNodeData } from '../canvasFlow';
import useCheckboxToggle from '../useCheckboxToggle';
import handlePositions from './handlePositions';
import useCardWheel from './useCardWheel';
import stripUnsafeStyles from './stripUnsafeStyles';
const logger = Logger.create('WhiteboardFileNode');
@@ -125,7 +124,7 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, resolved.body, {
resourceInfos: linkedResources,
});
if (!cancelled) setNoteHtml(stripUnsafeStyles(result?.html ?? ''));
if (!cancelled) setNoteHtml(result?.html ?? '');
} catch {
if (!cancelled) setNoteHtml('');
}
@@ -11,7 +11,6 @@ import { WhiteboardNodeData } from '../canvasFlow';
import useCheckboxToggle from '../useCheckboxToggle';
import handlePositions from './handlePositions';
import useCardWheel from './useCardWheel';
import stripUnsafeStyles from './stripUnsafeStyles';
const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
const [html, setHtml] = useState<string>('');
@@ -27,7 +26,7 @@ const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, md, {
resourceInfos: ctx.resourceInfos,
});
if (!cancelled) setHtml(stripUnsafeStyles(result?.html ?? ''));
if (!cancelled) setHtml(result?.html ?? '');
} catch {
if (!cancelled) setHtml('');
}
@@ -1,48 +0,0 @@
import stripUnsafeStyles from './stripUnsafeStyles';
describe('stripUnsafeStyles', () => {
test.each([
[
'removes a style element',
'<p>Hello</p><style>.rli-sideBar { outline: 3px solid magenta; }</style>',
'<p>Hello</p>',
],
[
'removes a style element containing a remote @import',
'<style>@import url(http://evil.example/beacon.css);</style><p>Hi</p>',
'<p>Hi</p>',
],
[
'removes multiple style elements',
'<style>a{}</style><p>x</p><style>b{}</style>',
'<p>x</p>',
],
[
'removes an uppercase STYLE element',
'<P>x</P><STYLE>@import url(http://evil.example/x.css);</STYLE>',
'<p>x</p>',
],
[
'removes a mixed-case Style element',
'<Style>a{}</Style><p>y</p>',
'<p>y</p>',
],
[
'keeps rich card content (images, formatting, links)',
'<p><strong>Bold</strong> <a href="https://example.com">link</a> <img src="x.png"></p>',
'<p><strong>Bold</strong> <a href="https://example.com">link</a> <img src="x.png"></p>',
],
[
'keeps inline style attributes, which cannot reach the app chrome',
'<p style="color: red">text</p>',
'<p style="color: red">text</p>',
],
])('should %s', (_label, input, expected) => {
expect(stripUnsafeStyles(input)).toBe(expected);
});
test('should return empty input unchanged', () => {
expect(stripUnsafeStyles('')).toBe('');
});
});
@@ -1,15 +0,0 @@
// Whiteboard cards render into the main document, not the isolated note-viewer
// iframe, so a note's <style> would reach the app chrome. Removing <style>
// elements also drops any @import; inline style="" attributes are kept as they
// only affect the card itself.
const stripUnsafeStyles = (html: string) => {
if (!html || !/<style/i.test(html)) return html;
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const style of Array.from(doc.querySelectorAll('style'))) {
style.remove();
}
return doc.body.innerHTML;
};
export default stripUnsafeStyles;
@@ -5,7 +5,7 @@ import { flipNthCheckbox } from './useCheckboxToggle';
// `flipNthCheckbox` must count exactly the same subset so the Nth rendered
// checkbox maps to the Nth source-text checkbox.
describe('useCheckboxToggle', () => {
describe('flipNthCheckbox', () => {
test('flips an unchecked box to checked', () => {
expect(flipNthCheckbox('- [ ] todo', 0)).toBe('- [x] todo');
});
@@ -18,7 +18,7 @@ import styles_ from './styles';
import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEditorRef, NoteBodyEditorPropsAndRef, NoteBodyEditorType } from './utils/types';
import CommandService from '@joplin/lib/services/CommandService';
import Button, { ButtonLevel } from '../Button/Button';
import eventManager, { EventName, NoteLockNoteStateChangeEvent } from '@joplin/lib/eventManager';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import { AppState } from '../../app.reducer';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { _, _n } from '@joplin/lib/locale';
@@ -28,10 +28,6 @@ import Setting from '@joplin/lib/models/Setting';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
import { itemIsReadOnly } from '@joplin/lib/models/utils/readOnly';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import NoteLockPanel from './NoteLockPanel/NoteLockPanel';
import { themeStyle } from '@joplin/lib/theme';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
import NoteSearchBar from '../NoteSearchBar';
@@ -66,7 +62,6 @@ import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
import getResourceBaseUrl from './utils/getResourceBaseUrl';
import useInitialCursorLocation from './utils/useInitialCursorLocation';
import NotePositionService, { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
import { blur } from '@joplin/lib/utils/focusHandler';
const debounce = require('debounce');
@@ -85,7 +80,6 @@ function NoteEditorContent(props: NoteEditorProps) {
const [showRevisions, setShowRevisions] = useState(false);
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
const [isReadOnly, setIsReadOnly] = useState<boolean>(false);
const [reloadInProgress, setReloadInProgress] = useState(false);
const editorRef = useRef<NoteBodyEditorRef|null>(null);
const titleInputRef = useRef<HTMLInputElement|null>(null);
@@ -98,20 +92,11 @@ function NoteEditorContent(props: NoteEditorProps) {
}, []);
const setFormNoteRef = useRef<OnSetFormNote>(null);
const formNoteRef = useRef<FormNote>(null);
const { saveNoteIfWillChange, scheduleSaveNote } = useScheduleSaveCallbacks({
setFormNote: setFormNoteRef, formNote: formNoteRef, dispatch: props.dispatch, editorRef, editorId,
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
setFormNote: setFormNoteRef, dispatch: props.dispatch, editorRef, editorId,
});
const formNote_beforeLoad = useCallback(async (event: OnLoadEvent) => {
await saveNoteIfWillChange(event.formNote);
// The lock must wait for the pending saves of the note being left: a locked note's save
// needs the unlocked session to encrypt, so locking first would drop the last edits.
// The note being left may have no form note at all (unlock or cannot-decrypt overlay).
if (isNoteLockEnabled() && Setting.value('noteLock.lockOnNoteSwitch')) {
await event.formNote.saveActionQueue?.waitForAllDone();
NoteLockSession.instance().lock();
}
setShowRevisions(false);
}, [saveNoteIfWillChange]);
@@ -122,37 +107,8 @@ function NoteEditorContent(props: NoteEditorProps) {
const effectiveNoteId = useEffectiveNoteId(props);
const { editorPlugin, editorView } = usePluginEditorView(props.plugins);
const builtInEditorVisible = !editorPlugin;
const windowId = useContext(WindowIdContext);
const onDecryptFailedChange = useCallback((value: boolean) => {
props.dispatch({ type: 'SET_ACTIVE_NOTE_IS_UNDECRYPTABLE', value, windowId });
}, [props.dispatch, windowId]);
const onReloadInProgressChange = useCallback((value: boolean) => {
if (value) {
// TinyMCE edits inside its own document. Blurring only the outer window does
// not reliably stop input when the editor is in a secondary window.
editorRef.current?.blurEditor?.();
// Blur only the window whose editor is being refreshed. A secondary window
// can refresh in response to a change saved by the main editor; blurring the
// global document here would incorrectly interrupt typing in the main window.
const editorWindowActiveElement = containerRef.current?.ownerDocument?.activeElement;
if (editorWindowActiveElement) {
blur('NoteEditor::reloadEditorWindow', editorWindowActiveElement);
}
}
setReloadInProgress(value);
if (!value) {
// TinyMCE can report onWillChange before a reload, then have its delayed
// onChange rejected while the reload is in progress. Since no save is queued
// in that case, clear the saving status when the reload completes.
props.dispatch({
type: 'EDITOR_NOTE_STATUS_REMOVE',
id: effectiveNoteId,
});
}
}, [effectiveNoteId, props.dispatch]);
const { formNote, setFormNote, isNewNote, resourceInfos, decryptFailed, loadBlocked } = useFormNote({
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
noteId: effectiveNoteId,
isProvisional: props.isProvisional,
titleInputRef: titleInputRef,
@@ -161,48 +117,14 @@ function NoteEditorContent(props: NoteEditorProps) {
onAfterLoad: formNote_afterLoad,
builtInEditorVisible,
editorId,
noteLockSessionUnlocked: props.noteLockSessionUnlocked,
onDecryptFailedChange,
onReloadInProgressChange,
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
});
setFormNoteRef.current = setFormNote;
const formNoteRef = useRef<FormNote>(formNote);
formNoteRef.current = { ...formNote };
useEffect(() => {
if (!isNoteLockEnabled()) return () => {};
// Pending scheduled saves read the lock state from the form note, so it must follow an
// enable/disable triggered outside the editor (note list menu, another window) immediately.
const onLockStateChange = (event: NoteLockNoteStateChangeEvent) => {
if (formNoteRef.current.id !== event.noteId) return;
// Enabling requires an unlocked session, so capture the key the same way a decrypt
// does - a pending save can then still encrypt if the session locks before it runs.
const noteLockKey = event.isLocked && NoteLockSession.instance().isUnlocked() ? NoteLockSession.instance().decryptedKey() : null;
const newFormNote = { ...formNoteRef.current, is_locked: event.isLocked ? 1 : 0, noteLockKey, isDecrypted: event.isLocked };
setFormNote(newFormNote);
void scheduleSaveNote(newFormNote);
};
eventManager.on(EventName.NoteLockNoteStateChange, onLockStateChange);
return () => {
eventManager.off(EventName.NoteLockNoteStateChange, onLockStateChange);
};
}, [setFormNote, scheduleSaveNote]);
useAsyncEffect(async () => {
if (!isNoteLockEnabled() || props.noteLockSessionUnlocked) return;
const lockedFormNote = formNoteRef.current;
if (!lockedFormNote?.is_locked || !lockedFormNote.hasChanged) return;
// The panel replaces the editor only once the edits are written, so flush them the same way
// a note switch does before locking.
await saveNoteIfWillChange(lockedFormNote);
// The editor dispatches its pending change when it unmounts, which would be saved over the
// flushed one - clearing the change id makes onFieldChange drop it, as a note switch does.
setFormNote(prev => ({ ...prev, bodyWillChangeId: 0 }));
await lockedFormNote.saveActionQueue?.waitForAllDone();
}, [props.noteLockSessionUnlocked, saveNoteIfWillChange]);
const formNoteFolder = useFolder({ folderId: formNote.parent_id });
const windowId = useContext(WindowIdContext);
const shownEditorViewIds = useVisiblePluginEditorViewIds(props.plugins, windowId);
useConnectToEditorPlugin({
startupPluginsLoaded: props.startupPluginsLoaded,
@@ -288,7 +210,6 @@ function NoteEditorContent(props: NoteEditorProps) {
}, [formNote.title, props.onTitleChange]);
const onFieldChange = useCallback(async (field: string, value: string, changeId = 0) => {
if (reloadInProgress) return;
if (!isMountedRef.current) {
// When the component is unmounted, various actions can happen which can
// trigger onChange events, for example the textarea might be cleared.
@@ -337,7 +258,7 @@ function NoteEditorContent(props: NoteEditorProps) {
// - debounced because many calls to scheduleSaveNote can resolve at once
scheduleNoteListResort();
}
}, [reloadInProgress, handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
}, [handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
const onDrop = useDropHandler({ editorRef });
@@ -496,16 +417,7 @@ function NoteEditorContent(props: NoteEditorProps) {
return <div style={emptyDivStyle} ref={containerRef}></div>;
}
const searchMarkers = useSearchMarkers({
showLocalSearch,
localSearchMarkerOptions,
noteId: props.noteId,
searchResults: props.searchResults,
searchId: props.selectedSearchId,
searches: props.searches,
highlightedWords: props.highlightedWords,
noteTitle: formNote.title,
});
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
const initialCursorLocation = useInitialCursorLocation({
noteId: props.noteId,
});
@@ -522,14 +434,13 @@ function NoteEditorContent(props: NoteEditorProps) {
content: formNote.body,
contentMarkupLanguage: markupLanguage,
contentOriginalCss: formNote.originalCss,
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
initialCursorLocation,
resourceInfos: resourceInfos,
resourceDirectory: Setting.value('resourceDir'),
htmlToMarkdown: htmlToMarkdown,
markupToHtml: markupToHtml,
allAssets: allAssets,
disabled: isReadOnly || reloadInProgress,
disabled: isReadOnly,
themeId: props.themeId,
dispatch: props.dispatch,
noteToolbar: null,
@@ -558,9 +469,7 @@ function NoteEditorContent(props: NoteEditorProps) {
useCustomPdfViewer: props.useCustomPdfViewer,
// We need it to identify the context for which media is rendered.
// It is currently used to remember pdf scroll position for each attachments of each note uniquely.
// Use the form note object to ensure the editor is associated with the loaded form note until onBeforeLoad
// has flushed any pending changes from it.
noteId: formNote.id,
noteId: props.noteId,
watchedNoteFiles: props.watchedNoteFiles,
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
showNoteLinkIcon: props.showNoteLinkIcon,
@@ -583,7 +492,7 @@ function NoteEditorContent(props: NoteEditorProps) {
}, [noteHasWhiteboardFence, props.dispatch]);
if (useWhiteboardEditor) {
editor = <WhiteboardEditor key={formNote.id} {...editorProps}/>;
editor = <WhiteboardEditor {...editorProps}/>;
} else if (builtInEditorVisible) {
if (props.bodyEditor === 'TinyMCE') {
editor = <TinyMCE {...editorProps}/>;
@@ -751,43 +660,7 @@ function NoteEditorContent(props: NoteEditorProps) {
/>;
};
// A locked note has no form note while the session is locked (see loadNoteForForm), so the
// panel is driven by the note metadata. A loaded form note stays mounted on lock only if it
// has unsaved changes, so they are not thrown away.
const lockedNoteMetadata = isNoteLockEnabled() && effectiveNoteId ? props.notes.find(n => n.id === effectiveNoteId) : null;
if (lockedNoteMetadata?.is_locked) {
// The session is unlocked but the note content failed to decrypt (e.g. it was encrypted
// prior to a password reset) - locking the session again shows the regular unlock panel.
if (decryptFailed && props.noteLockSessionUnlocked) {
return (
<div style={styles.root} ref={containerRef}>
<NoteLockPanel
noteTitle={lockedNoteMetadata.title}
hasNoteLockKey={props.hasNoteLockKey}
dispatch={props.dispatch}
undecryptable={true}
/>
</div>
);
}
const formNoteLoaded = formNote.id === effectiveNoteId;
const showLockPanel = formNoteLoaded
? !props.noteLockSessionUnlocked && !formNote.hasChanged
: !props.noteLockSessionUnlocked || loadBlocked;
if (showLockPanel) {
return (
<div style={styles.root} ref={containerRef}>
<NoteLockPanel
noteTitle={lockedNoteMetadata.title}
hasNoteLockKey={props.hasNoteLockKey}
dispatch={props.dispatch}
/>
</div>
);
}
}
if ((!editorPlugin && reloadInProgress) || formNote.encryption_applied || !formNote.id || !effectiveNoteId) {
if (formNote.encryption_applied || !formNote.id || !effectiveNoteId) {
return renderNoNotes(styles.root);
}
@@ -799,7 +672,6 @@ function NoteEditorContent(props: NoteEditorProps) {
const note = props.notes.find(n => n.id === props.selectedNoteIds[0]);
if (!note) return null;
if (note.markup_language !== MarkupLanguage.Html) return null;
if (isNoteLockEnabled() && note.is_locked && !props.noteLockSessionUnlocked) return null;
return (
<div style={styles.resourceWatchBanner}>
@@ -828,7 +700,7 @@ function NoteEditorContent(props: NoteEditorProps) {
noteTitle={formNote.title}
noteUserUpdatedTime={formNote.user_updated_time}
onTitleChange={onTitleChange}
disabled={isReadOnly || reloadInProgress}
disabled={isReadOnly}
/>
{renderSearchInfo()}
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%', minHeight: '0' }}>
@@ -853,15 +725,6 @@ interface ConnectProps {
windowId: string;
}
// Memoized because mapStateToProps runs on every dispatch and SyncInfo parses the cached JSON.
let hasNoteLockKeyCache: { syncInfoCache: string; value: boolean } = null;
const hasNoteLockKey = (syncInfoCache: string) => {
if (!hasNoteLockKeyCache || hasNoteLockKeyCache.syncInfoCache !== syncInfoCache) {
hasNoteLockKeyCache = { syncInfoCache, value: !!new SyncInfo(syncInfoCache).noteLockKey };
}
return hasNoteLockKeyCache.value;
};
const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
@@ -921,9 +784,6 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
enableInEditorRendering: state.settings['editor.inlineRendering'],
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
whiteboardForceMarkdown: windowState.whiteboardForceMarkdown ?? {},
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
hasNoteLockKey: hasNoteLockKey(state.settings['syncInfoCache']),
editorNoteReloadTimeRequest: windowState.windowEditorNoteReloadTimeRequest,
};
};
@@ -1,88 +0,0 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { Dispatch } from 'redux';
import { _ } from '@joplin/lib/locale';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import Button, { ButtonLevel } from '../../Button/Button';
import LabelledPasswordInput from '../../PasswordInput/LabelledPasswordInput';
interface Props {
noteTitle: string;
hasNoteLockKey: boolean;
dispatch: Dispatch;
undecryptable?: boolean;
}
export default function NoteLockPanel(props: Props) {
const [password, setPassword] = useState('');
const [unlocking, setUnlocking] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const onPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
}, []);
const unlock = useCallback(async () => {
if (!password || unlocking) return;
setUnlocking(true);
try {
await NoteLockSession.instance().unlock(password);
// No success handling: the unlock event swaps this panel out for the note.
} catch (error) {
// WebCrypto reports a wrong password as a generic OperationError.
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
setUnlocking(false);
}
}, [password, unlocking]);
const onSubmit = useCallback((event: React.FormEvent) => {
event.preventDefault();
void unlock();
}, [unlock]);
const onSetUpClick = useCallback(() => {
props.dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: { defaultSection: 'noteLock' },
});
}, [props.dispatch]);
const renderAction = () => {
if (props.undecryptable) {
return <p className="message">{_('This note could not be decrypted. If it was encrypted prior to a password reset, the contents are no longer recoverable.')}</p>;
}
if (!props.hasNoteLockKey) {
return (
<>
<p className="message">{_('Reading this note requires the note lock password, which has not been set up on this device yet.')}</p>
<Button level={ButtonLevel.Primary} title={_('Set up note lock')} onClick={onSetUpClick} />
</>
);
}
return (
<form className="form unlock-form" onSubmit={onSubmit}>
<p className="message">{_('This note is encrypted. Enter the note lock password to unlock encrypted notes for this session.')}</p>
<div className="field">
<LabelledPasswordInput
labelText={_('Note lock password')}
value={password}
onChange={onPasswordChange}
/>
</div>
{!!errorMessage && <p className="error" role="alert">{errorMessage}</p>}
<Button type='button' level={ButtonLevel.Primary} title={_('Unlock')} disabled={!password || unlocking} onClick={unlock} />
</form>
);
};
return (
<div className="note-lock-panel">
<i className="icon fas fa-lock" role="img" aria-label={_('Locked note')}></i>
<h2 className="title">{props.noteTitle}</h2>
{renderAction()}
</div>
);
}
@@ -1,47 +0,0 @@
.note-lock-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: var(--joplin-main-padding);
text-align: center;
}
.note-lock-panel > .icon {
font-size: 32px;
color: var(--joplin-color-faded);
margin-bottom: 16px;
}
.note-lock-panel > .title {
margin: 0 0 8px 0;
overflow-wrap: anywhere;
}
.note-lock-panel > .message {
max-width: 420px;
color: var(--joplin-color-faded);
margin-bottom: 16px;
}
.note-lock-panel > .unlock-form {
display: flex;
flex-direction: column;
align-items: center;
}
.note-lock-panel > .unlock-form > .message {
max-width: 420px;
color: var(--joplin-color-faded);
margin-bottom: 16px;
}
.note-lock-panel > .unlock-form > .field {
margin-bottom: 16px;
}
.note-lock-panel > .unlock-form > .error {
color: var(--joplin-color-error);
margin: 0 0 12px 0;
}
@@ -2,6 +2,7 @@ import { FormNote } from './types';
import HtmlToMd, { ParseOptions } from '@joplin/lib/HtmlToMd';
import Note from '@joplin/lib/models/Note';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { MarkupToHtml } from '@joplin/renderer';
export async function htmlToMarkdown(markupLanguage: number, html: string, originalCss: string, parseOptions: ParseOptions = null): Promise<string> {
@@ -25,7 +26,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
return newBody;
}
export async function formNoteToNote(formNote: FormNote) {
export async function formNoteToNote(formNote: FormNote): Promise<NoteEntity> {
return {
id: formNote.id,
// Should also include parent_id and deleted_time so that the reducer
@@ -36,7 +37,5 @@ export async function formNoteToNote(formNote: FormNote) {
is_conflict: formNote.is_conflict,
title: formNote.title,
body: formNote.body,
is_locked: formNote.is_locked,
isDecrypted: formNote.isDecrypted,
};
}
@@ -16,8 +16,6 @@ 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 type { DecryptedNoteLockKey } from '@joplin/lib/services/noteLock/NoteLockKey';
import { HighlightedWord, SearchEntry } from '@joplin/lib/reducer';
export interface AllAssetsOptions {
contentMaxWidthTarget?: string;
@@ -49,12 +47,13 @@ export interface NoteEditorProps {
notesParentType: string;
selectedNoteTags: TagEntity[];
selectedNoteHash: string;
searches: SearchEntry[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- searches: any[] is the shape in lib's reducer; tightening requires updating the reducer first
searches: any[];
selectedSearchId: string;
customCss: string;
noteVisiblePanes: string[];
watchedResources: Record<string, unknown>;
highlightedWords: HighlightedWord[];
highlightedWords: string[];
tabMovesFocus: boolean;
plugins: PluginStates;
toolbarButtonInfos: ToolbarItem[];
@@ -74,14 +73,10 @@ export interface NoteEditorProps {
enableHtmlToMarkdownBanner: boolean;
showNoteLinkIcon: boolean;
whiteboardForceMarkdown: Record<string, boolean>;
noteLockSessionUnlocked: boolean;
hasNoteLockKey: boolean;
editorNoteReloadTimeRequest: number;
}
export interface NoteBodyEditorRef {
content(): string|Promise<string>;
blurEditor?(): void;
resetScroll(): void;
scrollTo(options: ScrollOptions): void;
@@ -118,7 +113,6 @@ export interface NoteBodyEditorProps {
contentKey: string;
contentMarkupLanguage: number;
contentOriginalCss: string;
editorNoteReloadTimeRequest: number;
initialCursorLocation: EditorCursorLocations;
onChange(event: OnChangeEvent): void;
onWillChange(event: { changeId: number }): void;
@@ -174,12 +168,6 @@ export interface FormNote {
markup_language: number;
user_updated_time: number;
encryption_applied: number;
is_locked: number;
// The key captured when the locked note was decrypted; pending saves encrypt with it so they
// can complete even if the session locks before they run.
noteLockKey: DecryptedNoteLockKey|null;
// Proof for gated saves that the body is plaintext.
isDecrypted: boolean;
deleted_time: number;
hasChanged: boolean;
@@ -232,9 +220,6 @@ export function defaultFormNote(): FormNote {
hasChanged: false,
user_updated_time: 0,
encryption_applied: 0,
is_locked: 0,
noteLockKey: null,
isDecrypted: false,
};
}
@@ -1,15 +1,11 @@
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils';
import { act, renderHook, waitFor } from '@testing-library/react';
import useFormNote, { HookDependencies } from './useFormNote';
import shim from '@joplin/lib/shim';
import Resource from '@joplin/lib/models/Resource';
import ItemChange from '@joplin/lib/models/ItemChange';
import { join } from 'path';
import { formNoteToNote } from '.';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
const defaultFormNoteProps: HookDependencies = {
noteId: '',
@@ -20,16 +16,6 @@ const defaultFormNoteProps: HookDependencies = {
onAfterLoad: () => { },
editorId: 'editor',
builtInEditorVisible: false,
noteLockSessionUnlocked: false,
onDecryptFailedChange: () => {},
};
const deferred = <T>() => {
let resolve: (value: T)=> void;
const promise = new Promise<T>(resolvePromise => {
resolve = resolvePromise;
});
return { promise, resolve: resolve! };
};
describe('useFormNote', () => {
@@ -38,99 +24,6 @@ describe('useFormNote', () => {
await switchClient(1);
});
// The session and decryption internals are covered by the lib tests; here they are mocked to
// test only the hook's gating: ciphertext must never reach the form note.
it('should not produce a form note for a locked note while the session is locked, and decrypt it once unlocked', async () => {
Setting.setValue('featureFlag.noteLock', true);
// A direct save of a new note with is_locked keeps the raw body, standing in for ciphertext.
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(false);
const decryptedKeyMock = jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => ({ ...note, body: 'secret content' }));
const onBeforeLoad = jest.fn();
const onDecryptFailedChange = jest.fn();
try {
const lockedRender = renderHook(props => useFormNote(props), {
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange },
});
// The blocked load produces no state change to wait for; onBeforeLoad is its last
// await, so once it has run a single flush completes the branch.
await waitFor(() => expect(onBeforeLoad).toHaveBeenCalled());
await act(async () => {});
expect(lockedRender.result.current.formNote).toMatchObject({
id: '',
body: '',
noteLockKey: null,
});
expect(lockedRender.result.current.loadBlocked).toBe(true);
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(false);
isUnlockedMock.mockReturnValue(true);
lockedRender.rerender({ ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange, noteLockSessionUnlocked: true });
await waitFor(() => {
expect(lockedRender.result.current.formNote).toMatchObject({
id: testNote.id,
is_locked: 1,
body: 'secret content',
noteLockKey: { id: 'key-id', plainText: 'key' },
});
});
expect(lockedRender.result.current.loadBlocked).toBe(false);
lockedRender.unmount();
Setting.setValue('featureFlag.noteLock', false);
isUnlockedMock.mockReturnValue(false);
const flagOffRender = renderHook(props => useFormNote(props), {
initialProps: { ...defaultFormNoteProps, noteId: testNote.id },
});
await waitFor(() => {
expect(flagOffRender.result.current.formNote.id).toBe(testNote.id);
});
expect(flagOffRender.result.current.formNote).toMatchObject({
body: 'ciphertext',
});
flagOffRender.unmount();
} finally {
isUnlockedMock.mockRestore();
decryptedKeyMock.mockRestore();
decryptBodyMock.mockRestore();
Setting.setValue('featureFlag.noteLock', false);
}
});
it('should report a decryption failure instead of throwing, without producing a form note', async () => {
Setting.setValue('featureFlag.noteLock', true);
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
// The save's own change event would otherwise reach the hook's listener and reload the note.
await ItemChange.waitForAllSaved();
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockRejectedValue(new Error('OperationError'));
const onDecryptFailedChange = jest.fn();
try {
const render = renderHook(props => useFormNote(props), {
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, noteLockSessionUnlocked: true, onDecryptFailedChange },
});
await waitFor(() => {
expect(render.result.current.decryptFailed).toBe(true);
});
expect(render.result.current.formNote).toMatchObject({
id: '',
body: '',
});
expect(render.result.current.loadBlocked).toBe(false);
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(true);
render.unmount();
} finally {
isUnlockedMock.mockRestore();
decryptBodyMock.mockRestore();
Setting.setValue('featureFlag.noteLock', false);
}
});
it('should update note when decryption completes', async () => {
const testNote = await Note.save({ title: 'Test Note!' });
@@ -208,7 +101,7 @@ describe('useFormNote', () => {
is_conflict: 1,
title: testNote.title,
});
}, { timeout: 15_000 });
});
// Should preserve is_conflict after save.
expect(await formNoteToNote(formNote.result.current.formNote)).toMatchObject({
@@ -222,12 +115,10 @@ describe('useFormNote', () => {
it('should reload the note when it is changed outside of the editor', async () => {
const note = await Note.save({ title: 'Test Note!', body: '...' });
const onReloadInProgressChange = jest.fn();
const props = {
...defaultFormNoteProps,
noteId: note.id,
onReloadInProgressChange,
};
const formNote = renderHook(props => useFormNote(props), {
@@ -246,59 +137,10 @@ describe('useFormNote', () => {
await waitFor(() => {
expect(formNote.result.current.formNote.title).toBe('Modified');
});
expect(onReloadInProgressChange).not.toHaveBeenCalled();
formNote.unmount();
});
it('should force a reload when editorNoteReloadTimeRequest changes', async () => {
const note = await Note.save({ title: 'Original', body: '...' });
const onReloadInProgressChange = jest.fn();
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
act(() => {
formNote.result.current.setFormNote(previous => ({ ...previous, title: 'Unsaved local title', hasChanged: true }));
});
await Note.save({ id: note.id, title: 'Remote title' }, { changeId: 'test-editor' });
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenCalledWith(true));
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Remote title'));
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
formNote.unmount();
});
it('should clear reloadInProgress after overlapping reload requests', async () => {
const note = await Note.save({ title: 'Original', body: '...' });
const onReloadInProgressChange = jest.fn();
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
const firstLoad = deferred<typeof note>();
const lastLoad = deferred<typeof note>();
const loadMock = jest.spyOn(Note, 'load')
.mockImplementationOnce(() => firstLoad.promise)
.mockImplementationOnce(() => lastLoad.promise);
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(1));
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 2 });
firstLoad.resolve({ ...note, title: 'First reload' });
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(2));
expect(onReloadInProgressChange).toHaveBeenLastCalledWith(true);
lastLoad.resolve({ ...note, title: 'Last reload' });
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Last reload'));
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
loadMock.mockRestore();
formNote.unmount();
});
test('should refresh resource infos when changed outside the editor', async () => {
let note = await Note.save({});
note = await shim.attachFileToNote(note, join(supportDir, 'sample.txt'));
@@ -9,15 +9,12 @@ import attachedResources, { clearResourceCache } from '@joplin/lib/utils/attache
import { MarkupToHtml } from '@joplin/renderer';
import Note from '@joplin/lib/models/Note';
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { focus } from '@joplin/lib/utils/focusHandler';
import Logger from '@joplin/utils/Logger';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
import { NoteEntity } from '@joplin/lib/services/database/types';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
const logger = Logger.create('useFormNote');
@@ -34,10 +31,6 @@ export interface HookDependencies {
onBeforeLoad(event: OnLoadEvent): void;
onAfterLoad(event: OnLoadEvent): void;
builtInEditorVisible: boolean;
noteLockSessionUnlocked: boolean;
onDecryptFailedChange(value: boolean): void;
onReloadInProgressChange?(value: boolean): void;
editorNoteReloadTimeRequest?: number;
}
type MapFormNoteCallback = (previousFormNote: FormNote)=> FormNote;
@@ -73,41 +66,16 @@ function resourceInfosChanged(a: ResourceInfos, b: ResourceInfos): boolean {
return false;
}
// While the session is locked, a locked note produces no form note at all - the unlock panel
// takes the editor's place. So no placeholder body exists that a save could write over the note.
const loadNoteForForm = async (noteId: string): Promise<{ note: NoteEntity|null; blocked: boolean; decryptFailed: boolean }> => {
if (isNoteLockEnabled()) {
const lockState = await Note.load(noteId, { fields: ['is_locked'] });
if (lockState && NoteLockNote.isLocked(lockState)) {
if (!NoteLockSession.instance().isUnlocked()) return { note: null, blocked: true, decryptFailed: false };
try {
return { note: await Note.load(noteId, { useNoteLock: true }), blocked: false, decryptFailed: false };
} catch (error) {
// Happens when the note was encrypted with a different key, e.g. before a password
// reset. Fail closed and let the editor show the cannot-decrypt panel.
logger.warn('Could not decrypt locked note:', noteId, error);
return { note: null, blocked: false, decryptFailed: true };
}
}
}
return { note: await Note.load(noteId), blocked: false, decryptFailed: false };
};
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean, force?: boolean)=> Promise<FormNote>;
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId: string, noteId: string, initNoteState: InitNoteStateCallback, clearFormNote: ()=> void, builtInEditorVisible: boolean, noteLockSessionUnlocked: boolean, setDecryptFailed: (value: boolean)=> void, setLoadBlocked: (value: boolean)=> void, onReloadInProgressChange: (value: boolean)=> void, editorNoteReloadTimeRequest: number) => {
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean)=> Promise<FormNote>;
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId: string, noteId: string, initNoteState: InitNoteStateCallback, builtInEditorVisible: boolean) => {
// Increasing the value of this counter cancels any ongoing note refreshes and starts
// a new refresh.
const [formNoteRefreshRequest, setFormNoteRefreshRequest] = useState({ counter: 0, force: false });
const previousEditorNoteReloadTimeRequestRef = useRef(editorNoteReloadTimeRequest);
const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);
const prevBuiltInEditorVisible = usePrevious<boolean>(builtInEditorVisible);
// Seeded because usePrevious defaults to null, which would read as a session change on mount.
const prevNoteLockSessionUnlocked = usePrevious<boolean>(noteLockSessionUnlocked, noteLockSessionUnlocked);
useQueuedAsyncEffect(async (event) => {
if (event.cancelled) return;
if (formNoteRefreshRequest.counter <= 0) return;
const forceRefresh = formNoteRefreshRequest.force;
if (formNoteRef.current.hasChanged && !forceRefresh) {
if (formNoteRefreshScheduled <= 0) return;
if (formNoteRef.current.hasChanged) {
logger.info('Form note changed between scheduling a refresh and the refresh itself. Cancelling the refresh.');
return;
}
@@ -115,58 +83,38 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
logger.info('Sync has finished and note has never been changed - reloading it');
const loadNote = async () => {
setDecryptFailed(false);
const { note: n, blocked, decryptFailed } = await loadNoteForForm(noteId);
if (event.cancelled || (formNoteRef.current.hasChanged && !forceRefresh)) return;
const n = await Note.load(noteId);
if (event.cancelled || formNoteRef.current.hasChanged) return;
setLoadBlocked(blocked);
setDecryptFailed(decryptFailed);
if (blocked || decryptFailed) {
// The session locked with this note open (or its content stopped decrypting) - drop
// the decrypted content and let the corresponding panel take over.
if (formNoteRef.current.id) clearFormNote();
} else {
// Normally should not happened because if the note has been deleted via sync
// it would not have been loaded in the editor (due to note selection changing
// on delete)
if (!n) {
logger.warn('Trying to reload note that has been deleted:', noteId);
return;
}
await initNoteState(n, false, forceRefresh);
if (event.cancelled) return;
// Normally should not happened because if the note has been deleted via sync
// it would not have been loaded in the editor (due to note selection changing
// on delete)
if (!n) {
logger.warn('Trying to reload note that has been deleted:', noteId);
return;
}
setFormNoteRefreshRequest(oldValue => {
await initNoteState(n, false);
if (event.cancelled) return;
setFormNoteRefreshScheduled(oldValue => {
// If a new refresh was scheduled between initNoteState
// and now:
if (oldValue.counter !== formNoteRefreshRequest.counter) {
if (oldValue !== formNoteRefreshScheduled) {
return oldValue;
}
// A refresh is no longer scheduled
return { counter: 0, force: false };
return 0;
});
};
try {
await loadNote();
} finally {
if (forceRefresh && !event.cancelled) onReloadInProgressChange(false);
}
}, [formNoteRefreshRequest, noteId, editorId, initNoteState, clearFormNote, setDecryptFailed, onReloadInProgressChange]);
await loadNote();
}, [formNoteRefreshScheduled, noteId, editorId, initNoteState]);
const refreshFormNote = useCallback((force = false) => {
const refreshFormNote = useCallback(() => {
// Increase the counter to cancel any ongoing refresh attempts
// and start a new one.
if (force) onReloadInProgressChange(true);
setFormNoteRefreshRequest(previous => ({ counter: previous.counter + 1, force: previous.force || force }));
}, [onReloadInProgressChange]);
useEffect(() => {
if (editorNoteReloadTimeRequest === previousEditorNoteReloadTimeRequestRef.current) return;
previousEditorNoteReloadTimeRequestRef.current = editorNoteReloadTimeRequest;
refreshFormNote(true);
}, [editorNoteReloadTimeRequest, refreshFormNote]);
setFormNoteRefreshScheduled(count => count + 1);
}, []);
// When switching from the plugin editor to the built-in editor, we refresh the note since the
// plugin may have modified it via the data API.
@@ -176,14 +124,6 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
}
}, [builtInEditorVisible, prevBuiltInEditorVisible, refreshFormNote]);
// Unlocking loads the note the panel was blocking (the form is then empty), locking must drop
// a locked note's plaintext - reload to recompute.
useEffect(() => {
if (isNoteLockEnabled() && prevNoteLockSessionUnlocked !== noteLockSessionUnlocked && (formNoteRef.current.is_locked || formNoteRef.current.id !== noteId)) {
refreshFormNote();
}
}, [noteLockSessionUnlocked, prevNoteLockSessionUnlocked, noteId, formNoteRef, refreshFormNote]);
useEffect(() => {
if (!noteId) return ()=>{};
@@ -213,38 +153,18 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
export default function useFormNote(dependencies: HookDependencies) {
const {
noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, builtInEditorVisible, editorId, noteLockSessionUnlocked, onDecryptFailedChange,
noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, builtInEditorVisible, editorId,
} = dependencies;
const onReloadInProgressChange = dependencies.onReloadInProgressChange ?? (() => {});
const editorNoteReloadTimeRequest = dependencies.editorNoteReloadTimeRequest ?? 0;
const [formNote, setFormNote] = useState<FormNote>(defaultFormNote());
const [isNewNote, setIsNewNote] = useState(false);
// Keyed by note id so a failure can never carry over to another note the user switches to.
const [decryptFailedId, setDecryptFailedId] = useState<string|null>(null);
const setDecryptFailed = useCallback((value: boolean) => {
setDecryptFailedId(value ? noteId : null);
onDecryptFailedChange(value);
}, [noteId, onDecryptFailedChange]);
const [loadBlockedId, setLoadBlockedId] = useState<string|null>(null);
const setLoadBlocked = useCallback((value: boolean) => setLoadBlockedId(value ? noteId : null), [noteId]);
const previousNoteId = usePrevious(formNote.id);
const [resourceInfos, setResourceInfos] = useState<ResourceInfos>({});
const formNoteRef = useRef(formNote);
formNoteRef.current = formNote;
const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote, force = false) => {
let noteLockKey = null;
if (isNoteLockEnabled() && NoteLockNote.isLocked(n)) {
// The session can lock between the gated load and here (e.g. lock-on-switch) - drop
// the load rather than show plaintext that pending saves could no longer encrypt.
if (!NoteLockSession.instance().isUnlocked()) return null;
// Capture the key with the plaintext so pending saves can re-encrypt even after
// the session locks.
noteLockKey = NoteLockSession.instance().decryptedKey();
}
const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote) => {
let originalCss = '';
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
@@ -268,9 +188,6 @@ export default function useFormNote(dependencies: HookDependencies) {
hasChanged: false,
user_updated_time: n.user_updated_time,
encryption_applied: n.encryption_applied,
is_locked: n.is_locked,
noteLockKey,
isDecrypted: 'isDecrypted' in n && !!n.isDecrypted,
};
logger.debug('Initializing note state');
@@ -283,7 +200,7 @@ export default function useFormNote(dependencies: HookDependencies) {
// If the user changes the note while resources are loading, this can lead to
// a note being incorrectly marked as "unchanged".
if (!isNewNote && formNoteRef.current?.hasChanged && !force) {
if (!isNewNote && formNoteRef.current?.hasChanged) {
logger.info('Cancelled note refresh -- form note changed while loading attached resources.');
return null;
}
@@ -298,16 +215,10 @@ export default function useFormNote(dependencies: HookDependencies) {
return newFormNote;
}, []);
const clearFormNote = useCallback(() => {
formNoteRef.current = defaultFormNote();
setFormNote(formNoteRef.current);
}, []);
useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, clearFormNote, builtInEditorVisible, noteLockSessionUnlocked, setDecryptFailed, setLoadBlocked, onReloadInProgressChange, editorNoteReloadTimeRequest);
useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, builtInEditorVisible);
useEffect(() => {
if (!noteId) {
setDecryptFailed(false);
if (formNote.id) setFormNote(defaultFormNote());
return () => {};
}
@@ -333,21 +244,8 @@ export default function useFormNote(dependencies: HookDependencies) {
}
async function loadNote() {
setDecryptFailed(false);
const { note: n, blocked, decryptFailed } = await loadNoteForForm(noteId);
const n = await Note.load(noteId);
if (cancelled) return;
setLoadBlocked(blocked);
setDecryptFailed(decryptFailed);
if (blocked || decryptFailed) {
await onBeforeLoad({ formNote });
if (cancelled) return;
// Drop the previous form so no note content lingers behind the unlock or
// cannot-decrypt panel. The id guard stops the resulting effect re-run from looping.
if (formNoteRef.current.id) clearFormNote();
return;
}
if (!n) throw new Error(`Cannot find note with ID: ${noteId}`);
logger.debug('Loaded note:', n);
@@ -429,7 +327,5 @@ export default function useFormNote(dependencies: HookDependencies) {
formNote,
setFormNote: onSetFormNote,
resourceInfos,
decryptFailed: !!noteId && decryptFailedId === noteId,
loadBlocked: !!noteId && loadBlockedId === noteId,
};
}
@@ -1,5 +1,5 @@
import Logger from '@joplin/utils/Logger';
import { RefObject, useCallback, useRef } from 'react';
import { RefObject, useCallback } from 'react';
import { FormNote, NoteBodyEditorRef } from './types';
import { formNoteToNote } from '.';
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
@@ -7,44 +7,27 @@ import Note from '@joplin/lib/models/Note';
import type { Dispatch } from 'redux';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import type { OnSetFormNote } from './useFormNote';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
const logger = Logger.create('useScheduleSaveCallbacks');
interface Props {
setFormNote: RefObject<OnSetFormNote>;
formNote: RefObject<FormNote>;
editorId: string;
dispatch: Dispatch;
editorRef: RefObject<NoteBodyEditorRef>;
editorNoteReloadTimeRequest: number;
}
const useScheduleSaveCallbacks = (props: Props) => {
const editorNoteReloadTimeRequestRef = useRef(props.editorNoteReloadTimeRequest);
editorNoteReloadTimeRequestRef.current = props.editorNoteReloadTimeRequest;
const scheduleSaveNote = useCallback((formNote: FormNote) => {
if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check
// reg.logger().debug('Scheduling...', formNote);
const editorNoteReloadTimeRequest = editorNoteReloadTimeRequestRef.current;
const makeAction = (formNote: FormNote) => {
return async function() {
if (editorNoteReloadTimeRequestRef.current > editorNoteReloadTimeRequest) return;
// The lock state may change between scheduling and execution (e.g. encryption enabled
// from the note list menu), so the save uses the latest form state for this note.
const latestFormNote = props.formNote.current?.id === formNote.id ? props.formNote.current : formNote;
let note;
if (isNoteLockEnabled()) {
note = await formNoteToNote({ ...formNote, is_locked: latestFormNote.is_locked, isDecrypted: latestFormNote.isDecrypted });
} else {
note = await formNoteToNote(formNote);
}
logger.debug('Saving note...', isNoteLockEnabled() && note.is_locked ? note.id : note);
const savedNote = await Note.save(note, { changeId: `editorChange-${props.editorId}`, useNoteLock: true, noteLockKey: latestFormNote.noteLockKey });
const note = await formNoteToNote(formNote);
logger.debug('Saving note...', note);
const savedNote = await Note.save(note, { changeId: `editorChange-${props.editorId}` });
props.setFormNote.current((prev: FormNote) => {
return { ...prev, user_updated_time: savedNote.user_updated_time, hasChanged: false };
@@ -63,7 +46,7 @@ const useScheduleSaveCallbacks = (props: Props) => {
formNote.saveActionQueue.push(makeAction(formNote));
return formNote.saveActionQueue.waitForAllDone();
}, [props.dispatch, props.editorId, props.setFormNote, props.formNote]);
}, [props.dispatch, props.editorId, props.setFormNote]);
const saveNoteIfWillChange = useCallback(async (formNote: FormNote) => {
if (!formNote.id || !formNote.bodyWillChangeId || !props.editorRef.current) return;
@@ -76,7 +59,6 @@ const useScheduleSaveCallbacks = (props: Props) => {
bodyWillChangeId: 0,
bodyChangeId: 0,
});
await formNote.saveActionQueue.processAllNow();
}, [scheduleSaveNote, props.editorRef]);
return { saveNoteIfWillChange, scheduleSaveNote };
@@ -1,63 +0,0 @@
import { runWithFakeTimers, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { act, renderHook, waitFor } from '@testing-library/react';
import useSearchMarkers from './useSearchMarkers';
import Note from '@joplin/lib/models/Note';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { Second } from '@joplin/utils/time';
import { setUpSemanticSearch, tearDownSemanticSearch, updateSearchIndex } from '@joplin/lib/testing/ai/semanticSearch';
describe('useSearchMarkers', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setUpSemanticSearch();
});
afterEach(async () => {
await tearDownSemanticSearch();
});
it.each([
{
label: 'should return markers for semantic search results',
noteBody: 'example example example example',
searchQuery: 'examples example example example',
expected: [{ type: 'text', value: 'example example example example' }],
},
{
label: 'should exclude Markdown from keywords',
noteBody: '*test-test-test-test*! test',
// cSpell:disable
searchQuery: 'testt-test-test-test',
// cSpell:enable
expected: [{ type: 'text', value: 'test-test-test-test' }],
},
])('should return keywords for semantic search matches: $label', async ({ noteBody, searchQuery, expected }) => {
const note = await Note.save({ title: 'test', body: noteBody });
await updateSearchIndex();
await runWithFakeTimers(async () => {
const searchResults = await SearchEngine.instance().search(searchQuery);
const test = renderHook(() => useSearchMarkers({
showLocalSearch: false,
localSearchMarkerOptions: () => null,
noteId: note.id,
searchResults,
searchId: '1',
searches: [{ id: '1', query_pattern: searchQuery, query_type: 1, type_: -1, title: searchQuery }],
highlightedWords: [],
noteTitle: note.title,
}));
await act(() => jest.advanceTimersByTimeAsync(5 * Second));
await waitFor(() => {
const result = test.result.current;
expect(result.keywords).toMatchObject(expected);
}, { timeout: 30 * Second });
test.unmount();
});
});
});
@@ -1,9 +1,4 @@
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
import { HighlightedWord, SearchEntry } from '@joplin/lib/reducer';
import SearchService, { SearchResult } from '@joplin/lib/services/ai/SearchService';
import { ProcessResultsRow, SearchType } from '@joplin/lib/services/search/SearchEngine';
import { Second } from '@joplin/utils/time';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo } from 'react';
interface SearchMarkersOptions {
searchTimestamp: number;
@@ -12,9 +7,8 @@ interface SearchMarkersOptions {
withSelection?: boolean;
}
export interface SearchMarkers {
keywords: HighlightedWord[];
keywords: { value: string; type?: string; accuracy?: string }[];
options: SearchMarkersOptions;
}
@@ -29,116 +23,16 @@ function defaultSearchMarkers(): SearchMarkers {
};
}
// The indexer can prepend the note title to the first chunk:
// Remove it so that the results are exact substrings of the note body
const removeNoteTitle = (result: SearchResult, noteTitle: string) => {
if (result.chunkIndex !== 0) return result;
let chunkText = result.chunkText.trim();
while (chunkText.startsWith(`${noteTitle}\n`)) {
chunkText = chunkText.substring(noteTitle.length).trim();
}
return {
...result,
chunkText,
};
};
interface UseSemanticSearchMatchesProps {
query: string;
noteId: string;
noteTitle: string;
}
// Returns matching substrings of the note for the given search query
const useSemanticSearchMatches = ({ query, noteId, noteTitle }: UseSemanticSearchMatchesProps) => {
const [matchingChunks, setMatchingChunks] = useState<string[]>([]);
const matchingChunksRef = useRef(matchingChunks);
matchingChunksRef.current = matchingChunks;
useEffect(() => {
if (!query && matchingChunksRef.current.length !== 0) {
setMatchingChunks([]);
}
}, [query]);
const noteTitleRef = useRef(noteTitle);
noteTitleRef.current = noteTitle;
useQueuedAsyncEffect(async (event) => {
if (!query) {
return;
}
const results = await SearchService.instance().search({
query: { text: query }, scope: { type: 'note', noteId }, relevance: 'strict',
});
if (event.cancelled) return;
const matches = [];
for (const result of results) {
const bestMatch = await SearchService.instance().bestMatchInResult(
query, removeNoteTitle(result, noteTitleRef.current),
);
if (event.cancelled) return;
matches.push(bestMatch);
}
setMatchingChunks(matches);
}, [query, noteId], { interval: Second });
return matchingChunks;
};
interface UseSearchMarkersProps {
showLocalSearch: boolean;
localSearchMarkerOptions: ()=> SearchMarkers;
noteId: string;
searchResults: ProcessResultsRow[];
searchId: string;
searches: SearchEntry[];
highlightedWords: HighlightedWord[];
noteTitle: string;
}
export default function useSearchMarkers({
showLocalSearch,
localSearchMarkerOptions,
noteId,
searchResults,
searchId,
searches,
highlightedWords,
noteTitle,
}: UseSearchMarkersProps) {
const currentNoteSearchResult = useMemo(() => {
return searchResults.find(result => result.id === noteId);
}, [searchResults, noteId]);
const semanticSearchQuery = useMemo(() => {
if (!currentNoteSearchResult) return null;
if (!currentNoteSearchResult.searchType.includes(SearchType.Semantic)) return null;
const search = searches.find(search => search.id === searchId);
return search.query_pattern;
}, [searches, searchId, currentNoteSearchResult]);
const semanticSearchMatches = useSemanticSearchMatches({
query: semanticSearchQuery, noteId, noteTitle,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-explicit-any -- Function type matches the original signature; any[] matches the lib reducer's searches shape and the heterogeneous highlightedWords shape (string[] at the call site, keyword shapes inside)
export default function useSearchMarkers(showLocalSearch: boolean, localSearchMarkerOptions: Function, searches: any[], selectedSearchId: string, highlightedWords: any[] = []) {
return useMemo((): SearchMarkers => {
if (showLocalSearch) return localSearchMarkerOptions();
const output = defaultSearchMarkers();
output.keywords = highlightedWords ?? [];
if (semanticSearchMatches.length) {
output.keywords = output.keywords.concat(semanticSearchMatches.map(match => (
{ type: 'text', accuracy: 'partial', value: match, source: 'semantic' }
)));
}
output.keywords = highlightedWords;
return output;
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, semanticSearchMatches]);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, searches, selectedSearchId]);
}
@@ -8,7 +8,7 @@ type Props = {
makeItemIndexVisible: (index: number)=> void;
};
describe('UseAutoScroll', () => {
describe('useAutoScroll', () => {
test('scrolls to the note when a new note is selected', () => {
const makeItemIndexVisible = jest.fn();
@@ -4,7 +4,6 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { Size } from '@joplin/utils/types';
import { Dispatch } from 'redux';
import { EventEmitter } from 'events';
import { HighlightedWord } from '@joplin/lib/reducer';
export interface Props {
themeId: number;
@@ -28,7 +27,7 @@ export interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- searches: any[] is the shape in lib's reducer; tightening requires updating the reducer first
searches: any[];
selectedSearchId: string;
highlightedWords: HighlightedWord[];
highlightedWords: string[];
provisionalNoteIds: string[];
visible: boolean;
focusedField: string;
@@ -7,20 +7,14 @@ import useItemElement from './utils/useItemElement';
import { ItemEventHandlers, OnInputChange } from './utils/types';
import Note from '@joplin/lib/models/Note';
import { NoteEntity } from '@joplin/lib/services/database/types';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import bridge from '../../services/bridge';
import { _ } from '@joplin/lib/locale';
import useRenderedNote from './utils/useRenderedNote';
import { Dispatch } from 'redux';
import getNoteElementIdFromJoplinId from './utils/getNoteElementIdFromJoplinId';
import { HighlightedWord } from '@joplin/lib/reducer';
interface NoteItemProps {
dragIndex: number;
flow: ItemFlow;
highlightedWords: HighlightedWord[];
highlightedWords: string[];
index: number;
isProvisional: boolean;
itemSize: Size;
@@ -63,15 +57,6 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
};
if (changeEvent.elementId === 'todo-checkbox') {
if (isNoteLockEnabled()) {
const lockState = await Note.load(changeEvent.noteId, { fields: ['is_locked'] });
if (NoteLockNote.isLocked(lockState) && !NoteLockSession.instance().isUnlocked()) {
// event.currentTarget is already cleared here, after the await - target is the same input
event.target.checked = !changeEvent.value;
bridge().showErrorMessageBox(_('Cannot change a locked note while the session is locked'));
return;
}
}
await Note.save({
id: changeEvent.noteId,
todo_completed: changeEvent.value ? Date.now() : 0,
@@ -2,9 +2,8 @@ import { htmlentities } from '@joplin/utils/html';
const Mark = require('mark.js/dist/mark.min.js');
const markJsUtils = require('@joplin/lib/markJsUtils');
import { replaceRegexDiacritics, pregQuote } from '@joplin/lib/string-utils';
import { HighlightedWord } from '@joplin/lib/reducer';
const getNoteTitleHtml = (highlightedWords: HighlightedWord[], displayTitle: string) => {
const getNoteTitleHtml = (highlightedWords: string[], displayTitle: string) => {
if (highlightedWords.length) {
const titleElement = document.createElement('span');
titleElement.textContent = displayTitle;
@@ -11,7 +11,6 @@ import prepareViewProps from './prepareViewProps';
import Tag from '@joplin/lib/models/Tag';
import { unique } from '@joplin/lib/array';
import Folder from '@joplin/lib/models/Folder';
import { HighlightedWord } from '@joplin/lib/reducer';
interface RenderedNote {
id: string;
@@ -23,7 +22,7 @@ const hashContent = (content: unknown) => {
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
};
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: HighlightedWord[], itemIndex: number, columns: NoteListColumns) => {
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: string[], itemIndex: number, columns: NoteListColumns) => {
const [renderedNote, setRenderedNote] = useState<RenderedNote>(null);
let dependencies = columns && columns.length ? columns.map(c => c.name) as ListRendererDependency[] : [];
@@ -1,86 +0,0 @@
import * as React from 'react';
import { Dispatch } from 'redux';
import { useCallback, useState } from 'react';
import { _ } from '@joplin/lib/locale';
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
import Dialog from '@joplin/lib/components/Dialog';
import DialogTitle from '../DialogTitle';
import CommandService from '@joplin/lib/services/CommandService';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import LabelledPasswordInput from '../PasswordInput/LabelledPasswordInput';
interface SuccessCommand {
name: string;
args: string[];
}
interface Props {
themeId: number;
dispatch: Dispatch;
successCommand?: SuccessCommand;
}
export default function(props: Props) {
const [password, setPassword] = useState('');
const [unlocking, setUnlocking] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const onClose = useCallback(() => {
props.dispatch({
type: 'DIALOG_CLOSE',
name: 'noteLockUnlock',
});
}, [props.dispatch]);
const onPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
}, []);
const onButtonRowClick = useCallback(async (event: ClickEvent) => {
if (event.buttonName === 'cancel') {
onClose();
return;
}
if (event.buttonName === 'ok') {
setUnlocking(true);
try {
await NoteLockSession.instance().unlock(password);
} catch (error) {
// WebCrypto reports a wrong password as a generic OperationError.
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
setUnlocking(false);
return;
}
setUnlocking(false);
onClose();
if (props.successCommand) {
void CommandService.instance().execute(props.successCommand.name, ...props.successCommand.args);
}
}
}, [password, onClose, props.successCommand]);
return (
<Dialog onCancel={onClose} className="note-lock-unlock-dialog">
<div className="dialog-root">
<DialogTitle title={_('Unlock encrypted notes')}/>
<div className="dialog-content">
<p>{_('Enter the note lock password to unlock encrypted notes for this session.')}</p>
<LabelledPasswordInput
labelText={_('Note lock password')}
value={password}
onChange={onPasswordChange}
/>
{!!errorMessage && <p className="error-message" role="alert">{errorMessage}</p>}
</div>
<DialogButtonRow
themeId={props.themeId}
onClick={onButtonRowClick}
okButtonLabel={_('Unlock')}
okButtonDisabled={!password || unlocking}
cancelButtonDisabled={unlocking}
/>
</div>
</Dialog>
);
}
@@ -35,7 +35,6 @@ export interface NoteViewerControl {
focusLine(editorLine: number): void;
focus(): void;
hasFocus(): boolean;
stopMediaPlayback(): void;
}
const usePluginMessageResponder = (webviewRef: RefObject<HTMLIFrameElement>) => {
@@ -144,10 +143,6 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
if (channel === 'setMarkers') {
win.postMessage({ target: 'webview', name: 'setMarkers', data: { keywords: arg0, options: arg1 } }, '*');
}
if (channel === 'stopMediaPlayback') {
win.postMessage({ target: 'webview', name: 'stopMediaPlayback', data: {} }, '*');
}
},
focus: () => {
if (webviewRef.current) {
@@ -173,9 +168,6 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
}, 100);
}
},
stopMediaPlayback: () => {
result.send('stopMediaPlayback');
},
};
return result;
}, [parentDoc]);
@@ -35,7 +35,7 @@ const PasswordInput = (props: Props) => {
value={props.value}
onChange={props.onChange}
/>
<button type="button" onClick={onShowPassword} className="showpasswordbutton">
<button onClick={onShowPassword} className="showpasswordbutton">
<i className={icon} role='img' aria-label={title} title={title}></i>
</button>
</div>
@@ -7,7 +7,6 @@
display: flex;
flex: 1;
width: 100%;
padding-right: 28px;
}
> .showpasswordbutton {
@@ -1,6 +1,6 @@
import PromptDialog from './PromptDialog';
describe('PromptDialog', () => {
describe('PromptDialog Escape key handling', () => {
const setupKeyHandler = (inputType: string, menuIsOpened: boolean) => {
const onCloseMock = jest.fn();
@@ -1,9 +1,7 @@
import * as React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
import { _, _n } from '@joplin/lib/locale';
import Folder from '@joplin/lib/models/Folder';
import shim from '@joplin/lib/shim';
import DialogButtonRow, { ClickEvent } from './DialogButtonRow';
import Dialog from '@joplin/lib/components/Dialog';
import DialogTitle from './DialogTitle';
@@ -17,7 +15,6 @@ import { clipboard } from 'electron';
import useOnPublishFolderLinkClick from '@joplin/lib/components/shared/PublishFolderDialog/useOnPublishFolderLinkClick';
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
import Button from './Button/Button';
interface Props {
themeId: number;
@@ -31,7 +28,6 @@ export function PublishFolderDialog(props: Props) {
const [folder, setFolder] = useState<FolderEntity>(null);
const [noteCount, setNoteCount] = useState<number>(0);
const [publishFolderStatus, setPublishFolderStatus] = useState<SharingStatus>(SharingStatus.Unknown);
const [isUnpublishing, setIsUnpublishing] = useState(false);
const encryptionWarning = useEncryptionWarningMessage();
const publishedShare = useMemo(() => {
@@ -72,37 +68,19 @@ export function PublishFolderDialog(props: Props) {
props.onClose();
}, [props]);
const onUnpublishFolderClick = useCallback(async () => {
setIsUnpublishing(true);
try {
await ShareService.instance().unpublishFolder(props.folderId);
} catch (error) {
void shim.showErrorDialog(JoplinServerApi.connectionErrorMessage(error as Error));
} finally {
setIsUnpublishing(false);
}
}, [props.folderId]);
const statusMessage = useShareStatusMessage({ sharesState: publishFolderStatus, noteCount: 1 });
return (
<Dialog>
<div className="form share-note-dialog">
<DialogTitle title={_('Publish Notebook')}/>
<div className="shared-note-list-item">
<div className="title">
<div className="folder-info">
<p>{_('Notebook: %s', folder ? folder.title : '...')}</p>
<p>{_('Status: %s', publishedShare ? _('Published') : _('Not published'))}</p>
<p>{_n('%d note in notebook', '%d notes in notebook', noteCount, noteCount)}</p>
</div>
</div>
{publishedShare && (
<Button disabled={isUnpublishing} tooltip={_('Unpublish notebook')} iconName="fas fa-share-alt" onClick={onUnpublishFolderClick}/>
)}
<div className="folder-info">
<p>{_('Notebook: %s', folder ? folder.title : '...')}</p>
<p>{_('Status: %s', publishedShare ? _('Published') : _('Not published'))}</p>
<p>{_n('%d note in notebook', '%d notes in notebook', noteCount, noteCount)}</p>
</div>
<button
disabled={isUnpublishing || !folder || [SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(publishFolderStatus) >= 0}
disabled={!folder || [SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(publishFolderStatus) >= 0}
className="share"
onClick={publishFolderLinkButton_click}
>{publishedShare ? _('Copy Shareable Link') : _('Publish notebook')}</button>
@@ -34,35 +34,43 @@ const LayoutItemContainer: React.FC<Props> = ({
const canResizeBottom = parentDir === LayoutItemDirection.Column && !isLastChild;
const className = `resizableLayoutItem rli-${item.key}`;
const enable = {
top: false,
right: canResizeRight,
bottom: canResizeBottom,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
if (canResizeRight || canResizeBottom) {
const enable = {
top: false,
right: canResizeRight,
bottom: canResizeBottom,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
return (
<Resizable
key={item.key}
className={className}
style={style}
size={size}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
enable={enable}
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
maxWidth={resizedItemMaxSize?.width}
maxHeight={resizedItemMaxSize?.height}
>
{children}
</Resizable>
);
return (
<Resizable
key={item.key}
className={className}
style={style}
size={size}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
enable={enable}
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
maxWidth={resizedItemMaxSize?.width}
maxHeight={resizedItemMaxSize?.height}
>
{children}
</Resizable>
);
} else {
return (
<div key={item.key} className={className} style={{ ...style, ...size }}>
{children}
</div>
);
}
};
export default LayoutItemContainer;
-2
View File
@@ -33,7 +33,6 @@ import SsoLoginScreen from './SsoLoginScreen/SsoLoginScreen';
import SamlShared from '@joplin/lib/components/shared/SamlShared';
import PopupNotificationProvider from './PopupNotification/PopupNotificationProvider';
import { ThemeProvider, StyleSheetManager, createGlobalStyle } from 'styled-components';
import QuitSyncDialog from './QuitSyncDialog';
interface Props {
themeId: number;
@@ -179,7 +178,6 @@ class RootComponent extends React.Component<Props, any> {
<MenuBar/>
<GlobalStyle/>
<WindowCommandsAndDialogs windowId={defaultWindowId} />
<QuitSyncDialog themeId={this.props.themeId} />
<Navigator style={navigatorStyle} screens={screens} className={`profile-${this.props.profileConfigCurrentProfileId}`} />
{this.renderSecondaryWindows()}
{this.renderModalMessage(this.modalDialogProps())}
@@ -170,6 +170,7 @@ function ShareFolderDialog(props: Props) {
useEffect(() => {
setRecipientSuggestions([]);
setLatestError(null);
let cancelled = false;
@@ -244,9 +245,7 @@ function ShareFolderDialog(props: Props) {
ShareService.instance().refreshShares(),
ShareService.instance().refreshShareUsers(share.id),
]);
// Only clear the input on success, otherwise the user loses the email
// they typed and the error message would be wiped by the suggestions effect.
if (!errorSet) setRecipientEmail('');
setRecipientEmail('');
await synchronize();
} catch (error) {
@@ -258,7 +257,6 @@ function ShareFolderDialog(props: Props) {
const recipientEmail_change = useCallback((value: string) => {
setRecipientEmail(value);
setLatestError(null);
}, []);
const renderRecipientSuggestion = useCallback((suggestedValue: string) => {
+13 -21
View File
@@ -17,7 +17,6 @@ import onUnshareNoteClick from '@joplin/lib/components/shared/ShareNoteDialog/on
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
import useEncryptionWarningMessage from '@joplin/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage';
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
import useIsPublished from '@joplin/lib/components/shared/ShareNoteDialog/useIsPublished';
import { clipboard } from 'electron';
interface Props {
@@ -67,10 +66,22 @@ export function ShareNoteDialog(props: Props) {
recursiveShare,
});
const renderNote = (note: NoteEntity) => {
const unshareButton = !props.shares.find(s => s.note_id === note.id) ? null : (
<Button tooltip={_('Unpublish note')} iconName="fas fa-share-alt" onClick={() => onUnshareNoteClick({ noteId: note.id })}/>
);
return (
<div key={note.id} className='shared-note-list-item'>
<span className='title'>{note.title}</span>{unshareButton}
</div>
);
};
const renderNoteList = (notes: NoteEntity[]) => {
const noteComps = [];
for (const note of notes) {
noteComps.push(<NoteItem key={note.id} note={note} shares={props.shares}/>);
noteComps.push(renderNote(note));
}
return <div className="notes">{noteComps}</div>;
};
@@ -125,25 +136,6 @@ export function ShareNoteDialog(props: Props) {
);
}
interface NoteItemProps {
note: NoteEntity;
shares: StateShare[];
}
const NoteItem: React.FC<NoteItemProps> = ({ note, shares }) => {
const published = useIsPublished(note, shares);
const unshareButton = published && (
<Button tooltip={_('Unpublish note')} iconName='fas fa-share-alt' onClick={() => onUnshareNoteClick({ noteId: note.id })}/>
);
return (
<div className='shared-note-list-item'>
<span className='title'>{note.title}</span>{unshareButton}
</div>
);
};
const mapStateToProps = (state: AppState) => {
return {
shares: state.shareService.shares.filter(s => !!s.note_id),
+2 -4
View File
@@ -32,15 +32,13 @@ const SidebarComponent = (props: Props) => {
const renderSynchronizeButton = (type: string) => {
const label = type === 'sync' ? _('Synchronise') : _('Cancel');
const nothingToSync = type === 'sync' && !props.syncPending && syncCompletedWithoutError(props.syncReport);
const syncHasErrors = type === 'sync' && !props.syncPending && !!props.syncReport.errors?.length;
const iconName = syncHasErrors ? 'fas fa-exclamation-triangle' : nothingToSync ? 'fas fa-check' : 'icon-sync';
const iconName = nothingToSync ? 'fas fa-check' : 'icon-sync';
return (
<StyledSynchronizeButton
level={ButtonLevel.SidebarSecondary}
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'} ${nothingToSync ? '-synced' : ''} ${syncHasErrors ? '-error' : ''}`}
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'} ${nothingToSync ? '-synced' : ''}`}
iconName={iconName}
iconLabel={syncHasErrors ? _('Synchronisation error') : undefined}
key="sync_button"
title={label}
onClick={() => {
@@ -38,10 +38,6 @@
font-size: 0.85em;
}
&.-error > .icon {
font-size: 1.25em;
}
&:not(.-syncing):not(.-synced) > .icon {
animation: icon-fade-in-b 300ms ease-in-out;
}
@@ -0,0 +1,22 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { clipboard } from 'electron';
export const declaration: CommandDeclaration = {
name: 'copyNoteExternalLink',
label: () => _('Copy external link'),
iconName: 'fas fa-link',
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);
if (!noteId) return;
clipboard.writeText(getNoteCallbackUrl(noteId));
},
enabledCondition: 'oneNoteSelected',
};
};
@@ -1,35 +0,0 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import { disableNoteLock } from '@joplin/lib/services/noteLock/setNoteLockState';
import bridge from '../../../services/bridge';
export const declaration: CommandDeclaration = {
name: 'disableNoteEncryption',
label: () => _('Disable encryption'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
if (!isNoteLockEnabled()) return;
if (noteId === null) {
if (context.state.selectedNoteIds.length !== 1) return;
noteId = context.state.selectedNoteIds[0];
}
// Same pending-save guard as enableNoteEncryption.
if (context.state.editorNoteStatuses[noteId] === 'saving') {
bridge().showErrorMessageBox(_('This note is currently being saved. Please try again in a moment.'));
return;
}
try {
await disableNoteLock(noteId);
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
},
enabledCondition: 'oneNoteSelected && noteIsLocked && noteLockSessionUnlocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder',
};
};
@@ -19,6 +19,6 @@ export const runtime = (): CommandRuntime => {
});
}
},
enabledCondition: '!noteIsReadOnlyShare',
enabledCondition: '!noteIsReadOnly',
};
};
@@ -1,70 +0,0 @@
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { DesktopCommandContext } from '../../../services/commands/types';
import { _ } from '@joplin/lib/locale';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import { enableNoteLock } from '@joplin/lib/services/noteLock/setNoteLockState';
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
import bridge from '../../../services/bridge';
export const declaration: CommandDeclaration = {
name: 'enableNoteEncryption',
label: () => _('Enable encryption'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: DesktopCommandContext, noteId: string = null) => {
if (!isNoteLockEnabled()) return;
if (noteId === null) {
if (context.state.selectedNoteIds.length !== 1) return;
noteId = context.state.selectedNoteIds[0];
}
// An open editor's pending save still has the old lock state and would fail against
// the note once its lock state changes underneath it.
if (context.state.editorNoteStatuses[noteId] === 'saving') {
bridge().showErrorMessageBox(_('This note is currently being saved. Please try again in a moment.'));
return;
}
if (!NoteLockKey.instance().load()) {
const setUpNow = bridge().showConfirmMessageBox(_('Encrypting a note requires a note lock password, which has not yet been set. Set it up now?'), {
buttons: [_('Yes'), _('No')],
});
if (setUpNow) {
context.dispatch({
type: 'NAV_GO',
routeName: 'Config',
props: { defaultSection: 'noteLock' },
});
}
return;
}
// A note that is not yet encrypted has no unlock overlay, so the session unlock has to
// happen through the dialog here.
if (!NoteLockSession.instance().isUnlocked()) {
context.dispatch({
type: 'DIALOG_OPEN',
name: 'noteLockUnlock',
props: { successCommand: { name: 'enableNoteEncryption', args: [noteId] } },
});
return;
}
// An externally edited note has a plaintext file on disk; flush and delete it before locking.
if (context.state.watchedNoteFiles.includes(noteId)) {
await ExternalEditWatcher.instance().stopWatching(noteId);
}
try {
await enableNoteLock(noteId);
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
},
enabledCondition: 'oneNoteSelected && !noteIsLocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder',
};
};
@@ -2,11 +2,10 @@
import * as addNoteToWhiteboard from './addNoteToWhiteboard';
import * as addProfile from './addProfile';
import * as commandPalette from './commandPalette';
import * as copyNoteExternalLink from './copyNoteExternalLink';
import * as deleteFolder from './deleteFolder';
import * as disableNoteEncryption from './disableNoteEncryption';
import * as duplicateNote from './duplicateNote';
import * as editAlarm from './editAlarm';
import * as enableNoteEncryption from './enableNoteEncryption';
import * as exportPdf from './exportPdf';
import * as globalRedo from './globalRedo';
import * as globalUndo from './globalUndo';
@@ -14,7 +13,6 @@ import * as gotoAnything from './gotoAnything';
import * as hideModalMessage from './hideModalMessage';
import * as importFrom from './importFrom';
import * as linkToNote from './linkToNote';
import * as lockEncryptedNotes from './lockEncryptedNotes';
import * as moveToFolder from './moveToFolder';
import * as newFolder from './newFolder';
import * as newNote from './newNote';
@@ -61,11 +59,10 @@ const index: any[] = [
addNoteToWhiteboard,
addProfile,
commandPalette,
copyNoteExternalLink,
deleteFolder,
disableNoteEncryption,
duplicateNote,
editAlarm,
enableNoteEncryption,
exportPdf,
globalRedo,
globalUndo,
@@ -73,7 +70,6 @@ const index: any[] = [
hideModalMessage,
importFrom,
linkToNote,
lockEncryptedNotes,
moveToFolder,
newFolder,
newNote,
@@ -34,6 +34,6 @@ export const runtime = (): CommandRuntime => {
return result;
},
enabledCondition: 'oneNoteSelected && (markdownEditorPaneVisible || richTextEditorVisible) && !noteIsReadOnly && !noteIsDeleted',
enabledCondition: 'markdownEditorPaneVisible || richTextEditorVisible',
};
};
@@ -1,19 +0,0 @@
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
export const declaration: CommandDeclaration = {
name: 'lockEncryptedNotes',
label: () => _('Lock encrypted notes'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async () => {
if (!isNoteLockEnabled()) return;
NoteLockSession.instance().lock();
},
enabledCondition: 'noteLockSessionUnlocked',
};
};
@@ -64,6 +64,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
}
}
},
enabledCondition: 'someNotesSelected && !noteIsReadOnlyShare',
enabledCondition: 'someNotesSelected && !noteIsReadOnly',
};
};
@@ -21,6 +21,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
bridge().showErrorMessageBox(error.message);
}
},
enabledCondition: 'someNotesSelected && !noteLockContentUnavailable',
enabledCondition: 'someNotesSelected',
};
};
@@ -2,8 +2,6 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale';
import Tag from '@joplin/lib/models/Tag';
import { TagEntity } from '@joplin/lib/services/database/types';
import hasLockedNoteWhileSessionLocked from '@joplin/lib/services/noteLock/hasLockedNoteWhileSessionLocked';
import bridge from '../../../services/bridge';
import { WindowControl } from '../utils/useWindowControl';
export const declaration: CommandDeclaration = {
@@ -22,11 +20,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
execute: async (context: CommandContext, noteIds: string[] = null) => {
noteIds = noteIds || context.state.selectedNoteIds;
if (await hasLockedNoteWhileSessionLocked(noteIds)) {
bridge().showErrorMessageBox(_('Tags cannot be changed for locked notes while the session is locked'));
return;
}
const tags = await Tag.commonTagsByNoteIds(noteIds);
const startTags: TagOption[] = tags
.map((a: TagEntity) => {
@@ -81,6 +74,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
},
});
},
enabledCondition: 'someNotesSelected && !allSelectedNotesAreDeleted && !inTrash',
enabledCondition: 'someNotesSelected && !inTrash',
};
};
@@ -12,7 +12,7 @@ jest.mock('../../../services/bridge', () => ({
}),
}));
describe('showSpellCheckerMenu', () => {
describe('mapStateToTitle', () => {
test('should return null if spellchecker.enabled is false', () => {
@@ -1,8 +1,6 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import hasLockedNoteWhileSessionLocked from '@joplin/lib/services/noteLock/hasLockedNoteWhileSessionLocked';
import bridge from '../../../services/bridge';
export const declaration: CommandDeclaration = {
name: 'toggleNoteType',
@@ -14,11 +12,6 @@ export const runtime = (): CommandRuntime => {
execute: async (context: CommandContext, noteIds: string[] = null) => {
if (noteIds === null) noteIds = context.state.selectedNoteIds;
if (await hasLockedNoteWhileSessionLocked(noteIds)) {
bridge().showErrorMessageBox(_('Cannot change a locked note while the session is locked'));
return;
}
for (let i = 0; i < noteIds.length; i++) {
const note = await Note.load(noteIds[i]);
await Note.save(Note.toggleIsTodo(note), { userSideValidation: true });
@@ -3,7 +3,6 @@ import { Dispatch } from 'redux';
import SyncWizardDialog from '../../SyncWizard/Dialog';
import MasterPasswordDialog from '../../MasterPasswordDialog/Dialog';
import NoteLockUnlockDialog from '../../NoteLockUnlockDialog/Dialog';
import EditFolderDialog from '../../EditFolderDialog/Dialog';
import PdfViewer from '../../PdfViewer';
@@ -33,13 +32,6 @@ const appDialogs: Record<string, RegisteredDialog> = {
},
},
noteLockUnlock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
render: (props: RegisteredDialogProps, customProps: any) => {
return <NoteLockUnlockDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId} {...customProps}/>;
},
},
editFolder: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
render: (props: RegisteredDialogProps, customProps: any) => {
+1 -3
View File
@@ -2,6 +2,7 @@ export default function() {
return [
'attachFile',
'copyDevCommand',
'copyNoteExternalLink',
'exportPdf',
'focusElementNoteBody',
'focusElementNoteViewer',
@@ -37,9 +38,6 @@ export default function() {
'textSelectAll',
'textBulletedList',
'toggleExternalEditing',
'enableNoteEncryption',
'disableNoteEncryption',
'lockEncryptedNotes',
'openNoteInNewWindow',
'toggleLayoutMoveMode',
'resetLayout',
@@ -430,26 +430,6 @@
}
};
// https://github.com/laurent22/joplin/issues/15278
// Embedded players (e.g. YouTube) are cross-origin iframes that can't be
// paused from here, so reassigning the src reloads them, which stops playback.
function stopMediaPlayback() {
for (const element of contentElement.querySelectorAll('video, audio')) {
if (!element.paused) element.pause();
}
// Exclude .media-pdf: it's the interactive PDF viewer, not a media
// player, and reloading it would reset the current page and scroll.
for (const iframe of contentElement.querySelectorAll('iframe:not(.media-pdf)')) {
const src = iframe.getAttribute('src');
if (src) iframe.setAttribute('src', src);
}
}
ipc.stopMediaPlayback = () => {
stopMediaPlayback();
};
ipc.setHtml = (event) => {
const html = event.html;
@@ -459,8 +439,6 @@
alreadyAllImagesLoaded = false;
stopMediaPlayback();
contentElement.innerHTML = html;
if (html.includes('file://')) {
@@ -5,8 +5,8 @@
left: 0;
width: 100%;
height: 100%;
padding: 20px;
align-items: flex-start;
justify-content: center;
border: none;
margin: 0;
background-color: transparent;
@@ -20,12 +20,11 @@
color: var(--joplin-color);
padding: 16px;
box-shadow: 6px 6px 20px rgba(0,0,0,0.5);
margin: 20px;
min-height: fit-content;
display: flex;
flex-direction: column;
border-radius: 10px;
margin-left: auto;
margin-right: auto;
}
&::backdrop {
@@ -35,7 +34,6 @@
&.-fullscreen {
max-width: 100vw;
max-height: 100vh;
padding: 0;
&::backdrop {
background-color: var(--joplin-background-color);
Loaded 100 of 450 files, more files were not shown because too many files have changed in this diff. Show more