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
132 changed files with 1714 additions and 4115 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
+13 -32
View File
@@ -247,7 +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.js
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
packages/app-desktop/gui/MenuBar.js
packages/app-desktop/gui/MultiNoteActions.js
@@ -320,7 +319,6 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel
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
@@ -410,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
@@ -505,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
@@ -518,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
@@ -861,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
@@ -1150,7 +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/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
@@ -1594,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
@@ -1611,24 +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/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/settings.js
packages/lib/services/ai/types.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/commands/MenuUtils.js
@@ -1640,7 +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/isConflictResolutionEnabled.js
packages/lib/services/contextkey/contextkey.js
packages/lib/services/database/addMigrationFile.js
packages/lib/services/database/isSqliteSyntaxError.js
@@ -1720,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
@@ -1737,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
+13 -32
View File
@@ -273,7 +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.js
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
packages/app-desktop/gui/MenuBar.js
packages/app-desktop/gui/MultiNoteActions.js
@@ -346,7 +345,6 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel
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
@@ -436,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
@@ -531,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
@@ -544,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
@@ -887,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
@@ -1176,7 +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/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
@@ -1620,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
@@ -1637,24 +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/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/settings.js
packages/lib/services/ai/types.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/commands/MenuUtils.js
@@ -1666,7 +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/isConflictResolutionEnabled.js
packages/lib/services/contextkey/contextkey.js
packages/lib/services/database/addMigrationFile.js
packages/lib/services/database/isSqliteSyntaxError.js
@@ -1746,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
@@ -1763,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
@@ -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
+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
@@ -11,7 +11,7 @@
},
"nodejs": "24.12.0",
"pkg-config": "latest",
"python": "3.14.3",
"python": "3.14.2",
"bat": "latest",
"electron": {
"version": "latest",
+6 -5
View File
@@ -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 -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);
}
@@ -349,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'>
@@ -400,7 +385,7 @@ 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');
+7 -11
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';
@@ -154,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) {
@@ -1,102 +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;
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);
respond(info.params['x-error'], { errorMessage: error.message });
}
};
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,
@@ -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';
@@ -96,19 +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,
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]);
@@ -119,12 +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 { formNote, setFormNote, isNewNote, resourceInfos, decryptFailed, loadBlocked } = useFormNote({
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
noteId: effectiveNoteId,
isProvisional: props.isProvisional,
titleInputRef: titleInputRef,
@@ -133,46 +117,14 @@ function NoteEditorContent(props: NoteEditorProps) {
onAfterLoad: formNote_afterLoad,
builtInEditorVisible,
editorId,
noteLockSessionUnlocked: props.noteLockSessionUnlocked,
onDecryptFailedChange,
});
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,
@@ -708,42 +660,6 @@ 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 (formNote.encryption_applied || !formNote.id || !effectiveNoteId) {
return renderNoNotes(styles.root);
}
@@ -809,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);
@@ -877,8 +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']),
};
};
@@ -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,7 +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';
export interface AllAssetsOptions {
contentMaxWidthTarget?: string;
@@ -74,8 +73,6 @@ export interface NoteEditorProps {
enableHtmlToMarkdownBanner: boolean;
showNoteLinkIcon: boolean;
whiteboardForceMarkdown: Record<string, boolean>;
noteLockSessionUnlocked: boolean;
hasNoteLockKey: boolean;
}
export interface NoteBodyEditorRef {
@@ -171,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;
@@ -229,9 +220,6 @@ export function defaultFormNote(): FormNote {
hasChanged: false,
user_updated_time: 0,
encryption_applied: 0,
is_locked: 0,
noteLockKey: null,
isDecrypted: false,
};
}
@@ -1,5 +1,4 @@
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';
@@ -7,8 +6,6 @@ import shim from '@joplin/lib/shim';
import Resource from '@joplin/lib/models/Resource';
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: '',
@@ -19,8 +16,6 @@ const defaultFormNoteProps: HookDependencies = {
onAfterLoad: () => { },
editorId: 'editor',
builtInEditorVisible: false,
noteLockSessionUnlocked: false,
onDecryptFailedChange: () => {},
};
describe('useFormNote', () => {
@@ -29,97 +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 });
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!' });
@@ -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,8 +31,6 @@ export interface HookDependencies {
onBeforeLoad(event: OnLoadEvent): void;
onAfterLoad(event: OnLoadEvent): void;
builtInEditorVisible: boolean;
noteLockSessionUnlocked: boolean;
onDecryptFailedChange(value: boolean): void;
}
type MapFormNoteCallback = (previousFormNote: FormNote)=> FormNote;
@@ -71,33 +66,12 @@ 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)=> 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) => {
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 [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);
const prevBuiltInEditorVisible = usePrevious<boolean>(builtInEditorVisible);
const prevNoteLockSessionUnlocked = usePrevious<boolean>(noteLockSessionUnlocked);
useQueuedAsyncEffect(async (event) => {
if (formNoteRefreshScheduled <= 0) return;
@@ -109,28 +83,19 @@ 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);
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);
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;
}
await initNoteState(n, false);
if (event.cancelled) return;
setFormNoteRefreshScheduled(oldValue => {
// If a new refresh was scheduled between initNoteState
// and now:
@@ -143,7 +108,7 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
};
await loadNote();
}, [formNoteRefreshScheduled, noteId, editorId, initNoteState, clearFormNote, setDecryptFailed]);
}, [formNoteRefreshScheduled, noteId, editorId, initNoteState]);
const refreshFormNote = useCallback(() => {
// Increase the counter to cancel any ongoing refresh attempts
@@ -159,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 !== undefined && prevNoteLockSessionUnlocked !== noteLockSessionUnlocked && (formNoteRef.current.is_locked || formNoteRef.current.id !== noteId)) {
refreshFormNote();
}
}, [noteLockSessionUnlocked, prevNoteLockSessionUnlocked, noteId, formNoteRef, refreshFormNote]);
useEffect(() => {
if (!noteId) return ()=>{};
@@ -196,19 +153,11 @@ 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 [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>({});
@@ -216,16 +165,6 @@ export default function useFormNote(dependencies: HookDependencies) {
formNoteRef.current = formNote;
const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote) => {
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();
}
let originalCss = '';
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
@@ -249,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');
@@ -279,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);
useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, builtInEditorVisible);
useEffect(() => {
if (!noteId) {
setDecryptFailed(false);
if (formNote.id) setFormNote(defaultFormNote());
return () => {};
}
@@ -314,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);
@@ -410,7 +327,5 @@ export default function useFormNote(dependencies: HookDependencies) {
formNote,
setFormNote: onSetFormNote,
resourceInfos,
decryptFailed: !!noteId && decryptFailedId === noteId,
loadBlocked: !!noteId && loadBlockedId === noteId,
};
}
@@ -7,13 +7,11 @@ 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>;
@@ -27,17 +25,9 @@ const useScheduleSaveCallbacks = (props: Props) => {
const makeAction = (formNote: FormNote) => {
return async function() {
// 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 };
@@ -56,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;
@@ -7,11 +7,6 @@ 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';
@@ -62,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,
@@ -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,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 {
@@ -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,
@@ -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) => {
@@ -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',
@@ -100,7 +100,7 @@ describe('NoteListUtils', () => {
'deleteNote',
'separator',
'Copy Markdown link',
'Copy external link',
'copyNoteExternalLink',
'separator',
'Export',
@@ -5,7 +5,6 @@ import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import InteropServiceHelper from '../../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import bridge from '../../services/bridge';
import BaseModel from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
@@ -14,7 +13,6 @@ import { clipboard } from 'electron';
import { Dispatch } from 'redux';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { MarkupLanguage } from '@joplin/renderer';
import hasLockedNoteWhileSessionLocked from '@joplin/lib/services/noteLock/hasLockedNoteWhileSessionLocked';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
@@ -72,10 +70,6 @@ export default class NoteListUtils {
);
} else {
const switchNoteType = async (noteIds: string[], type: string) => {
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]);
const newNote = Note.changeNoteType(note, type);
@@ -138,12 +132,7 @@ export default class NoteListUtils {
if (noteIds.length === 1) {
menu.append(
new MenuItem({
label: _('Copy external link'),
click: () => {
clipboard.writeText(getNoteCallbackUrl(noteIds[0]));
},
}),
new MenuItem(menuUtils.commandToStatefulMenuItem('copyNoteExternalLink', noteIds[0])),
);
}
@@ -125,7 +125,7 @@ test.describe('wcag', () => {
await mainScreen.chatPanel.sendMessage('/reply-with test');
await mainScreen.chatPanel.sendMessage(
'/tool editor.appendToNote {"text": "test"}\n/reply-with done',
'/tool appendToNote {"text": "test"}\n/reply-with done',
);
await mainScreen.chatPanel.waitForMessageCount(5);
+11 -20
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.7.10",
"version": "3.7.9",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -28,7 +28,6 @@
"bugs": {
"url": "https://github.com/laurent22/joplin/issues"
},
"desktopName": "appimagekit-joplin",
"build": {
"appId": "net.cozic.joplin-desktop",
"compression": "normal",
@@ -51,13 +50,11 @@
"./node_modules/sqlite-vec-*/**"
],
"win": {
"signtoolOptions": {
"sign": "./sign.js",
"rfc3161TimeStampServer": "http://timestamp.digicert.com",
"publisherName": [
"Joplin"
]
},
"publisherName": [
"Joplin"
],
"sign": "./sign.js",
"rfc3161TimeStampServer": "http://timestamp.digicert.com",
"icon": "../../Assets/ImageSources/Joplin.ico",
"target": [
{
@@ -131,14 +128,11 @@
},
"linux": {
"icon": "../../Assets/LinuxIcons",
"syncDesktopName": true,
"category": "Office",
"desktop": {
"entry": {
"Icon": "joplin",
"MimeType": "x-scheme-handler/joplin;",
"StartupWMClass": "@joplin/app-desktop"
}
"Icon": "joplin",
"MimeType": "x-scheme-handler/joplin;",
"StartupWMClass": "@joplin/app-desktop"
},
"target": [
"AppImage",
@@ -147,9 +141,6 @@
"executableName": "joplin",
"maintainer": "Joplin Team <no-reply@joplinapp.org>",
"artifactName": "Joplin-${version}.${ext}"
},
"toolsets": {
"appimage": "1.0.3"
}
},
"homepage": "https://github.com/laurent22/joplin#readme",
@@ -157,7 +148,7 @@
"7zip-bin": "5.2.0",
"@axe-core/playwright": "4.11.2",
"@electron/notarize": "2.5.0",
"@electron/rebuild": "4.1.0",
"@electron/rebuild": "3.7.2",
"@fortawesome/fontawesome-free": "5.15.4",
"@joeattardi/emoji-button": "4.6.4",
"@joplin/default-plugins": "~3.7",
@@ -186,7 +177,7 @@
"compare-versions": "6.1.1",
"debounce": "1.2.1",
"electron": "42.3.0",
"electron-builder": "26.15.6",
"electron-builder": "24.13.3",
"electron-updater": "6.6.8",
"electron-window-state": "5.0.3",
"esbuild": "^0.27.0",
-1
View File
@@ -1,7 +1,6 @@
@use 'gui/ConfigScreen/style.scss' as config-screen;
@use 'gui/EditFolderDialog/style.scss' as edit-folder-dialog;
@use 'gui/EncryptionConfigScreen/style.scss' as encryption-config-screen;
@use 'gui/NoteEditor/NoteLockPanel/style.scss' as note-lock-panel;
@use 'gui/PasswordInput/style.scss' as password-input;
@use 'gui/lib/SearchInput/style.scss' as search-input;
@use 'gui/JoplinCloudConfigScreen.scss' as joplin-cloud-config-screen;
@@ -76,10 +76,6 @@ const useStyles = ({ theme, dragging, draggable, dragOffset, backgroundOpacity }
borderBottomLeftRadius: 0,
maxWidth: Math.min(400, windowWidth - menuGapRight - menuGapLeft),
shadowRadius: 4,
shadowColor: theme.color,
shadowOpacity: 0.15,
marginBottom: -spaceBelowScreenEdge,
userSelect: dragging ? 'none' : 'auto',
@@ -364,20 +360,21 @@ const BottomDrawer: React.FC<Props> = props => {
dragHandleRef,
});
const onScrollDragEnd = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const onContainerScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = event.nativeEvent.contentOffset.y;
// Use a smaller tolerance for smaller menus to ensure that they're still dismissible
const overscrollTolerance = Math.min(80, menuHeightRef.current / 4);
// On platforms that support overscroll (iOS), support menu dismissal through the
// native scrollview's overscroll behavior:
// On iOS, support menu dismissal through the native scrollview's overscroll behavior:
if (offsetY < -overscrollTolerance) {
// Start the animation at the current scroll position, to avoid a jump when starting
// the animation:
menuDragOffset.setValue(-offsetY);
void onHide();
} else {
onPanResponderScroll(event);
}
}, [onHide, menuDragOffset]);
}, [onHide, onPanResponderScroll, menuDragOffset]);
return <Modal
visible={props.visible}
@@ -395,8 +392,7 @@ const BottomDrawer: React.FC<Props> = props => {
containerStyle={styles.menuStyle}
animationType={reduceMotionEnabled ? 'fade' : 'none'}
scrollOverflow={{
onScroll: onPanResponderScroll,
onScrollEndDrag: onScrollDragEnd,
onScroll: onContainerScroll,
// Throttling scroll events avoids a warning on web
scrollEventThrottle: 30,
+17 -39
View File
@@ -1,7 +1,7 @@
import * as React from 'react';
import { memo, useCallback, useMemo } from 'react';
import { connect } from 'react-redux';
import { Text, StyleSheet, TextStyle, View, ViewStyle, AccessibilityInfo } from 'react-native';
import { Text, StyleSheet, TextStyle, ViewStyle, AccessibilityInfo } from 'react-native';
import Checkbox from './Checkbox';
import Note from '@joplin/lib/models/Note';
import time from '@joplin/lib/time';
@@ -13,9 +13,6 @@ import { NoteEntity } from '@joplin/lib/services/database/types';
import useOnLongPressProps from '../utils/hooks/useOnLongPressProps';
import MultiTouchableOpacity from './buttons/MultiTouchableOpacity';
import { escapeRegExp } from '@joplin/lib/string-utils';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
interface Props {
dispatch: Dispatch;
@@ -31,14 +28,13 @@ const useStyles = (themeId: number, showTopBorder: boolean) => {
return useMemo(() => {
const theme = themeStyle(themeId);
const listItemDivider: ViewStyle = {
const listItem: ViewStyle = {
borderTopWidth: showTopBorder ? 1 : 0,
borderTopColor: theme.dividerColor,
marginLeft: theme.marginLeft,
marginRight: theme.marginRight,
};
const selectionWrapper: ViewStyle = {
paddingTop: theme.marginTop,
paddingBottom: theme.marginBottom,
flexDirection: 'row',
// backgroundColor: theme.backgroundColor,
};
@@ -47,8 +43,6 @@ const useStyles = (themeId: number, showTopBorder: boolean) => {
flexGrow: 1,
flexShrink: 1,
alignSelf: 'stretch',
paddingTop: theme.marginTop,
paddingBottom: theme.marginBottom,
};
const listItemPressableWithCheckbox: ViewStyle = {
...listItemPressable,
@@ -56,8 +50,6 @@ const useStyles = (themeId: number, showTopBorder: boolean) => {
};
const listItemPressableWithoutCheckbox: ViewStyle = {
...listItemPressable,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
};
const listItemText: TextStyle = {
@@ -68,15 +60,13 @@ const useStyles = (themeId: number, showTopBorder: boolean) => {
const listItemTextWithCheckbox = { ...listItemText };
const selectionWrapper: ViewStyle = { };
const selectionWrapperSelected = { ...selectionWrapper };
selectionWrapperSelected.backgroundColor = theme.selectedColor;
selectionWrapperSelected.borderColor = theme.selectedColor;
selectionWrapperSelected.borderTopWidth = 1;
selectionWrapperSelected.borderBottomWidth = 1;
selectionWrapperSelected.marginVertical = -1;
return StyleSheet.create({
listItemDivider,
listItem,
listItemText,
selectionWrapper,
listItemPressableWithoutCheckbox,
@@ -89,7 +79,6 @@ const useStyles = (themeId: number, showTopBorder: boolean) => {
selectionWrapperSelected,
checkboxStyle: {
color: theme.color,
paddingLeft: theme.marginLeft,
paddingRight: 10,
},
checkedOpacityStyle: {
@@ -106,14 +95,6 @@ const NoteItemComponent: React.FC<Props> = memo(props => {
const todoCheckbox_change = useCallback(async (checked: boolean) => {
if (!props.note) return;
// Duplicates the locked-note guard in app-desktop/gui/NoteListItem/NoteListItem.tsx.
if (isNoteLockEnabled()) {
const lockState = await Note.load(props.note.id, { fields: ['is_locked'] });
if (NoteLockNote.isLocked(lockState) && !NoteLockSession.instance().isUnlocked()) {
throw new Error('Cannot change a locked note while the session is locked');
}
}
const newNote = {
id: props.note.id,
todo_completed: checked ? time.unixMs() : 0,
@@ -189,19 +170,16 @@ const NoteItemComponent: React.FC<Props> = memo(props => {
...onLongPressProps,
};
return (
<View style={opacityStyle}>
<View style={styles.listItemDivider}/>
<MultiTouchableOpacity
{...pressableProps}
containerProps={{
style: selectionWrapperStyle,
}}
onPress={onPress}
beforePressable={todoCheckbox}
>
<Text style={listItemTextStyle}>{displayedNoteTitle}</Text>
</MultiTouchableOpacity>
</View>
<MultiTouchableOpacity
{...pressableProps}
containerProps={{
style: [selectionWrapperStyle, opacityStyle, styles.listItem],
}}
onPress={onPress}
beforePressable={todoCheckbox}
>
<Text style={listItemTextStyle}>{displayedNoteTitle}</Text>
</MultiTouchableOpacity>
);
});
@@ -36,8 +36,6 @@ import SectionDescription from './SectionDescription';
import EnablePluginSupportPage from './plugins/EnablePluginSupportPage';
import getVersionInfoText from '../../../utils/getVersionInfoText';
import JoplinCloudConfig, { emailToNoteDescription, emailToNoteLabel } from './JoplinCloudConfig';
import NoteLockConfig from './NoteLockConfig';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import shim from '@joplin/lib/shim';
import SettingsToggle from './SettingsToggle';
import { UpdateSettingValueCallback } from './types';
@@ -554,10 +552,6 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
addSettingButton('e2ee_config_button', _('Encryption Config'), this.e2eeConfig_);
}
if (section.name === 'noteLock' && isNoteLockEnabled()) {
addSettingComponent(<NoteLockConfig key='note-lock-config'/>, [_('Password setup'), _('Note lock password')]);
}
if (section.name === 'joplinCloud') {
addSettingComponent(
<JoplinCloudConfig
@@ -1,51 +0,0 @@
import * as React from 'react';
import { Store } from 'redux';
import { AppState } from '../../../utils/types';
import TestProviderStack from '../../testing/TestProviderStack';
import NoteLockConfig from './NoteLockConfig';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import createMockReduxStore from '../../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../../utils/testing/setupGlobalStore';
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import { act, render, screen } from '../../../utils/testing/testingLibrary';
let store: Store<AppState>;
const WrappedNoteLockConfig: React.FC = () => {
return <TestProviderStack store={store}>
<NoteLockConfig/>
</TestProviderStack>;
};
describe('NoteLockConfig', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
NoteLockKey.destroyInstance();
store = createMockReduxStore();
setupGlobalStore(store);
});
afterEach(() => {
screen.unmount();
});
test('should show the password setup form only while no note lock key is set', async () => {
const { unmount } = render(<WrappedNoteLockConfig/>);
expect(screen.getByText('Password setup')).toBeVisible();
expect(screen.getByLabelText('Password')).toBeVisible();
// Seed the key via save() rather than create(): the mobile test crypto shim can't
// run real key generation.
act(() => {
NoteLockKey.instance().save({ id: 'test-note-lock-key' } as MasterKeyEntity);
});
expect(screen.queryByText('Password setup')).toBeNull();
expect(screen.queryByLabelText('Password')).toBeNull();
expect(screen.getByText(/Note lock password:.*Set/)).toBeVisible();
unmount();
});
});
@@ -1,153 +0,0 @@
import * as React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { View, Text, TextInput, Button, StyleSheet } from 'react-native';
import { connect } from 'react-redux';
import { themeStyle } from '../../global-style';
import { _ } from '@joplin/lib/locale';
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { State } from '@joplin/lib/reducer';
interface Props {
themeId: number;
hasNoteLockKey: boolean;
}
const NoteLockConfig = (props: Props) => {
const [password, setPassword] = useState('');
const [passwordRepeat, setPasswordRepeat] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const theme = useMemo(() => themeStyle(props.themeId), [props.themeId]);
const styles = useMemo(() => {
return StyleSheet.create({
container: {
padding: theme.margin,
},
titleText: {
fontWeight: 'bold',
fontSize: theme.fontSize,
marginTop: theme.marginTop,
marginBottom: 5,
color: theme.color,
},
normalText: {
fontSize: theme.fontSize,
color: theme.color,
marginBottom: 5,
},
boldText: {
fontWeight: 'bold',
},
passwordInput: {
marginTop: 5,
marginBottom: theme.itemMarginBottom,
color: theme.color,
borderWidth: 1,
borderColor: theme.dividerColor,
borderRadius: 3,
padding: 10,
},
errorText: {
fontSize: theme.fontSize,
color: theme.colorError,
marginBottom: theme.itemMarginBottom,
},
reminderText: {
fontSize: theme.fontSize,
fontWeight: 'bold',
color: theme.color,
marginTop: theme.itemMarginTop,
},
});
}, [theme]);
const hasKey = props.hasNoteLockKey;
const passwordMismatch = !!passwordRepeat && password !== passwordRepeat;
const canSave = !!password && !!passwordRepeat && !passwordMismatch && !saving;
const onCreate = useCallback(async () => {
if (!canSave) return;
setSaving(true);
setError('');
try {
await NoteLockKey.instance().create(password);
setPassword('');
setPasswordRepeat('');
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
}, [canSave, password]);
const onPasswordChange = useCallback((value: string) => {
setPassword(value);
setError('');
}, []);
const onPasswordRepeatChange = useCallback((value: string) => {
setPasswordRepeat(value);
setError('');
}, []);
const passwordLabelId = 'note-lock-password';
const repeatLabelId = 'note-lock-password-repeat';
const renderPasswordSetup = () => {
if (hasKey) return null;
return (
<View>
<Text style={styles.titleText} accessibilityRole='header'>{_('Password setup')}</Text>
<Text nativeID={passwordLabelId} style={styles.normalText}>{_('Password')}</Text>
<TextInput
accessibilityLabelledBy={passwordLabelId}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
style={styles.passwordInput}
secureTextEntry={true}
autoCapitalize='none'
autoCorrect={false}
textContentType='newPassword'
importantForAutofill='yes'
value={password}
onChangeText={onPasswordChange}
/>
<Text nativeID={repeatLabelId} style={styles.normalText}>{_('Repeat password')}</Text>
<TextInput
accessibilityLabelledBy={repeatLabelId}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
style={styles.passwordInput}
secureTextEntry={true}
autoCapitalize='none'
autoCorrect={false}
textContentType='newPassword'
importantForAutofill='yes'
value={passwordRepeat}
onChangeText={onPasswordRepeatChange}
/>
{passwordMismatch ? <Text style={styles.errorText}>{_('Passwords do not match')}</Text> : null}
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<Button title={_('Save')} disabled={!canSave} onPress={onCreate} />
<Text style={styles.reminderText}>{_('Please make sure you remember your password. It cannot be recovered if lost, and any data encrypted with it will become inaccessible.')}</Text>
</View>
);
};
return (
<View style={styles.container}>
<Text style={styles.normalText}><Text style={styles.boldText}>{_('Note lock password:')}</Text> {hasKey ? _('Set') : _('Not set')}</Text>
{renderPasswordSetup()}
</View>
);
};
export default connect((state: State) => {
return {
themeId: state.settings.theme,
hasNoteLockKey: !!new SyncInfo(state.settings['syncInfoCache']).noteLockKey,
};
})(NoteLockConfig);
+2 -2
View File
@@ -37,7 +37,7 @@
"@react-native-community/geolocation": "3.4.0",
"@react-native-community/netinfo": "11.5.2",
"@react-native-community/push-notification-ios": "1.12.0",
"@react-native-documents/picker": "12.0.1",
"@react-native-documents/picker": "12.0.0",
"@react-native-vector-icons/fontawesome5": "12.4.0",
"@react-native-vector-icons/get-image": "12.3.0",
"@react-native-vector-icons/ionicons": "12.5.0",
@@ -50,7 +50,7 @@
"crypto-browserify": "3.12.1",
"deprecated-react-native-prop-types": "5.0.0",
"events": "3.3.0",
"expo": "54.0.34",
"expo": "54.0.33",
"expo-audio": "1.1.1",
"expo-camera": "17.0.10",
"expo-image-manipulator": "14.0.8",
+4 -18
View File
@@ -229,24 +229,10 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
Setting.setValue('activeFolderId', newState.selectedFolderId);
}
let notesParent: NotesParent;
if (action.smartFilterId) {
notesParent = {
type: 'SmartFilter',
selectedItemId: action.smartFilterId,
};
} else if (action.tagId) {
notesParent = {
type: 'Tag',
selectedItemId: action.tagId,
};
} else {
notesParent = {
type: 'Folder',
selectedItemId: newState.selectedFolderId,
};
}
const notesParent: NotesParent = {
type: action.smartFilterId ? 'SmartFilter' : 'Folder',
selectedItemId: action.smartFilterId ? action.smartFilterId : newState.selectedFolderId,
};
Setting.setValue('notesParent', serializeNotesParent(notesParent));
}
+2 -20
View File
@@ -392,28 +392,10 @@ const buildStartupTasks = (
addTask('buildStartupTasks/go: initial route', async () => {
const folder = await getInitialActiveFolder();
const getNotesParent = async () => {
const notesParent = parseNotesParent(Setting.value('notesParent'), Setting.value('activeFolderId'));
if (notesParent.type === 'Tag' && !(await Tag.load(notesParent.selectedItemId))) {
return null;
}
return notesParent;
};
const notesParent = await getNotesParent();
const notesParent = parseNotesParent(Setting.value('notesParent'), Setting.value('activeFolderId'));
if (notesParent && notesParent.type === 'SmartFilter') {
dispatch({
type: 'NAV_GO',
routeName: 'Notes',
smartFilterId: notesParent.selectedItemId,
});
} else if (notesParent && notesParent.type === 'Tag') {
dispatch({
type: 'NAV_GO',
routeName: 'Notes',
tagId: notesParent.selectedItemId,
});
dispatch(DEFAULT_ROUTE);
} else if (!folder) {
dispatch(DEFAULT_ROUTE);
} else {
@@ -1,255 +0,0 @@
import { EditorSelection, Extension } from '@codemirror/state';
import createTestEditor from '../../../testing/createTestEditor';
import replaceBackslashEscapes from '../replaceBackslashEscapes';
import replaceBulletLists from '../replaceBulletLists';
import replaceFormatCharacters from '../replaceFormatCharacters';
import replaceInlineHtml from '../replaceInlineHtml';
import replaceLinks from '../replaceLinks';
interface TestCase {
label: string;
markdown: string;
renderedText: string;
selectionFrom: number;
selectionTo: number;
expectedSelectionFrom: number;
expectedSelectionTo: number;
expectedSyntaxTreeTags: string[];
extensions: Extension[];
}
const testCases: TestCase[] = [
{
label: 'bold text',
markdown: '**bold**\n',
renderedText: 'bold',
selectionFrom: 2,
selectionTo: 6,
expectedSelectionFrom: 0,
expectedSelectionTo: 8,
expectedSyntaxTreeTags: ['StrongEmphasis'],
extensions: [replaceFormatCharacters],
},
{
label: 'bold italic text',
markdown: '***bold italic***\n',
renderedText: 'bold italic',
selectionFrom: 3,
selectionTo: 14,
expectedSelectionFrom: 0,
expectedSelectionTo: 17,
expectedSyntaxTreeTags: ['Emphasis', 'StrongEmphasis'],
extensions: [replaceFormatCharacters],
},
{
label: 'heading',
markdown: '# Heading\n',
renderedText: 'Heading',
selectionFrom: 2,
selectionTo: 9,
expectedSelectionFrom: 0,
expectedSelectionTo: 9,
expectedSyntaxTreeTags: ['ATXHeading1'],
extensions: [replaceFormatCharacters],
},
{
label: 'blockquote',
markdown: '> Blockquote\n',
renderedText: ' Blockquote',
selectionFrom: 2,
selectionTo: 12,
expectedSelectionFrom: 0,
expectedSelectionTo: 12,
expectedSyntaxTreeTags: ['Blockquote'],
extensions: [replaceFormatCharacters],
},
{
label: 'link',
markdown: '[Joplin website](https://joplinapp.org)\n',
renderedText: 'Joplin website',
selectionFrom: 1,
selectionTo: 15,
expectedSelectionFrom: 0,
expectedSelectionTo: 39,
expectedSyntaxTreeTags: ['Link'],
extensions: [replaceLinks],
},
{
label: 'link with bold text',
markdown: '[**Joplin website**](https://joplinapp.org)\n',
renderedText: 'Joplin website',
selectionFrom: 3,
selectionTo: 17,
expectedSelectionFrom: 0,
expectedSelectionTo: 43,
expectedSyntaxTreeTags: ['Link', 'StrongEmphasis'],
extensions: [replaceFormatCharacters, replaceLinks],
},
{
label: 'inline HTML',
markdown: '<span style="color: red">red text</span>\n',
renderedText: 'red text',
selectionFrom: 25,
selectionTo: 33,
expectedSelectionFrom: 0,
expectedSelectionTo: 40,
expectedSyntaxTreeTags: ['HTMLTag'],
extensions: [replaceInlineHtml],
},
{
label: 'escaped asterisks',
markdown: '\\*literal asterisk\\*\n',
renderedText: '*literal asterisk*',
selectionFrom: 1,
selectionTo: 20,
expectedSelectionFrom: 0,
expectedSelectionTo: 20,
expectedSyntaxTreeTags: ['Escape'],
extensions: [replaceBackslashEscapes],
},
{
label: 'bullet list item',
markdown: '- item\n',
renderedText: '- item',
selectionFrom: 2,
selectionTo: 6,
expectedSelectionFrom: 0,
expectedSelectionTo: 6,
expectedSyntaxTreeTags: ['BulletList'],
extensions: [replaceBulletLists],
},
{
label: 'bullet list item with selected leading space',
markdown: '- item\n',
renderedText: '- item',
selectionFrom: 1,
selectionTo: 6,
expectedSelectionFrom: 0,
expectedSelectionTo: 6,
expectedSyntaxTreeTags: ['BulletList'],
extensions: [replaceBulletLists],
},
];
const directedTestCases = testCases.flatMap(testCase => [
{
...testCase,
direction: 'forward',
selection: EditorSelection.range(testCase.selectionFrom, testCase.selectionTo),
expectedSelection: {
anchor: testCase.expectedSelectionFrom,
head: testCase.expectedSelectionTo,
},
},
{
...testCase,
direction: 'backward',
selection: EditorSelection.range(testCase.selectionTo, testCase.selectionFrom),
expectedSelection: {
anchor: testCase.expectedSelectionTo,
head: testCase.expectedSelectionFrom,
},
},
]);
const fullyCoveredSelectionTestCases = [
{
label: 'hidden Markdown',
markdown: '**bold**\n',
selectionFrom: 0,
selectionTo: 2,
expectedSyntaxTreeTags: ['StrongEmphasis'],
extensions: [replaceFormatCharacters],
},
{
label: 'widget',
markdown: '- item\n',
selectionFrom: 0,
selectionTo: 1,
expectedSyntaxTreeTags: ['BulletList'],
extensions: [replaceBulletLists],
},
].flatMap(testCase => [
{
...testCase,
direction: 'forward',
selection: EditorSelection.range(testCase.selectionFrom, testCase.selectionTo),
expectedCursor: testCase.selectionTo,
},
{
...testCase,
direction: 'backward',
selection: EditorSelection.range(testCase.selectionTo, testCase.selectionFrom),
expectedCursor: testCase.selectionFrom,
},
]);
// Temporary CI diagnostic. Intermittently, `expect(textContent).toBe('red text')` fails with
// Expected: "red text" / Received: "red text" - visually identical but unequal, so the actual
// string differs by an invisible codepoint (e.g. char code 160/8203 where the healthy value has
// a normal space, 32). This logs the char codes on mismatch to identify it. Remove once diagnosed.
const expectTextContentToBe = (actual: string, expected: string) => {
if (actual !== expected) {
// eslint-disable-next-line no-console
console.error('FLAKE_DIAGNOSTIC', JSON.stringify({
expectedCodes: [...expected].map(character => character.charCodeAt(0)),
actualCodes: [...actual].map(character => character.charCodeAt(0)),
}));
}
expect(actual).toBe(expected);
};
describe('makeInlineReplaceExtension', () => {
it.each(directedTestCases)('should include Markdown syntax for $label in a $direction mouse selection', async ({
markdown, renderedText, selection, expectedSelection, expectedSyntaxTreeTags, extensions,
}) => {
const editor = await createTestEditor(
markdown,
EditorSelection.cursor(markdown.length),
expectedSyntaxTreeTags,
extensions,
);
try {
editor.dom.dispatchEvent(new MouseEvent('mousedown', { button: 0 }));
editor.dispatch({ selection, userEvent: 'select.pointer' });
expect(editor.state.selection.main).toMatchObject({
anchor: selection.anchor,
head: selection.head,
});
expectTextContentToBe(editor.contentDOM.textContent, renderedText);
editor.dom.ownerDocument.dispatchEvent(new MouseEvent('mouseup', { button: 0 }));
expect(editor.state.selection.main).toMatchObject(expectedSelection);
expectTextContentToBe(editor.contentDOM.textContent, markdown.trimEnd());
} finally {
editor.destroy();
}
});
it.each(fullyCoveredSelectionTestCases)('should turn a $direction $label selection into a cursor', async ({
markdown, selection, expectedCursor, expectedSyntaxTreeTags, extensions,
}) => {
const editor = await createTestEditor(
markdown,
EditorSelection.cursor(markdown.length),
expectedSyntaxTreeTags,
extensions,
);
try {
editor.dom.dispatchEvent(new MouseEvent('mousedown', { button: 0 }));
editor.dispatch({ selection, userEvent: 'select.pointer' });
editor.dom.ownerDocument.dispatchEvent(new MouseEvent('mouseup', { button: 0 }));
expect(editor.state.selection.main).toMatchObject({
anchor: expectedCursor,
head: expectedCursor,
});
} finally {
editor.destroy();
}
});
});
@@ -4,43 +4,13 @@
import { EditorView, Decoration, DecorationSet, WidgetType } from '@codemirror/view';
import { ViewPlugin, ViewUpdate } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { EditorSelection, Range, SelectionRange, StateEffect, TransactionSpec } from '@codemirror/state';
import { Range, StateEffect } from '@codemirror/state';
import { SyntaxNodeRef } from '@lezer/common';
import { ReplacementExtension } from '../types';
import nodeIntersectsSelection from './nodeIntersectsSelection';
const updateInlineDecorationsEffect = StateEffect.define();
const isHiddenDecoration = (decoration: Decoration) => (
!Object.keys(decoration.spec).length || decoration.spec.widget instanceof WidgetType
);
const expandSelectionToFormattingCharacters = (decorations: DecorationSet, selection: SelectionRange, docLength: number) => {
if (selection.empty) return null;
const hiddenRanges: { from: number; to: number }[] = [];
decorations.between(0, docLength, (from, to, decoration) => {
if (isHiddenDecoration(decoration)) hiddenRanges.push({ from, to });
});
let coveredTo = selection.from;
for (const range of hiddenRanges) {
if (range.from <= coveredTo) coveredTo = Math.max(coveredTo, range.to);
}
if (coveredTo >= selection.to) return EditorSelection.single(selection.head);
let { from, to } = selection;
for (let index = hiddenRanges.length - 1; index >= 0; index--) {
if (hiddenRanges[index].to === from) from = hiddenRanges[index].from;
}
for (const range of hiddenRanges) {
if (range.from === to) to = range.to;
}
if (from === selection.from && to === selection.to) return null;
return selection.anchor <= selection.head ? EditorSelection.single(from, to) : EditorSelection.single(to, from);
};
export const makeInlineReplaceExtension = (extensionSpec: ReplacementExtension) => ViewPlugin.fromClass(class {
public decorations: DecorationSet;
private mouseSelectionInProgress = false;
@@ -65,14 +35,17 @@ export const makeInlineReplaceExtension = (extensionSpec: ReplacementExtension)
private onMouseUp = () => {
if (this.mouseSelectionInProgress) {
const selection = this.view.state.selection.main;
let selectionUpdate: TransactionSpec['selection'] = expandSelectionToFormattingCharacters(
this.decorations, selection, this.view.state.doc.length,
) ?? undefined;
let coveredTo = selection.from;
this.decorations.between(selection.from, selection.to, (from, to, decoration) => {
if (!Object.keys(decoration.spec).length && from <= coveredTo) {
coveredTo = Math.max(coveredTo, to);
}
});
const hasHiddenDecoration = (from: number, to: number) => {
let found = false;
this.decorations.between(from, to, (_from, _to, decoration) => {
if (isHiddenDecoration(decoration)) {
if (!Object.keys(decoration.spec).length) {
found = true;
return false;
}
@@ -81,26 +54,13 @@ export const makeInlineReplaceExtension = (extensionSpec: ReplacementExtension)
return found;
};
let selectionUpdate = !selection.empty && coveredTo >= selection.to ? { anchor: selection.head } : undefined;
const line = this.view.state.doc.lineAt(selection.from);
syntaxTree(this.view.state).iterate({
from: line.from,
to: line.to,
enter: node => {
if (selectionUpdate) return;
const isHiddenPrefixBeforeSelection = (node.name === 'QuoteMark' || node.name === 'ListMark')
&& (node.name === 'ListMark' || node.from === line.from)
&& node.to < selection.from
&& !this.view.state.sliceDoc(node.to, selection.from).trim()
&& hasHiddenDecoration(node.from, node.to);
if (isHiddenPrefixBeforeSelection) {
selectionUpdate = selection.anchor <= selection.head
? EditorSelection.single(node.from, selection.to)
: EditorSelection.single(selection.to, node.from);
return;
}
if (node.name !== 'Link') return;
if (selectionUpdate || node.name !== 'Link') return;
const closingBracket = node.node.getChildren('LinkMark').find(mark => (
this.view.state.sliceDoc(mark.from, mark.to) === ']'
));
@@ -1,6 +1,6 @@
import { markdown } from '@codemirror/lang-markdown';
import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown';
import { forceParsing, indentUnit, syntaxTree } from '@codemirror/language';
import { indentUnit, syntaxTree } from '@codemirror/language';
import { SelectionRange, EditorSelection, EditorState, Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import forceFullParse from './forceFullParse';
@@ -70,12 +70,6 @@ const createTestEditor = async (
}
}
// forceFullParse populates the parse cache for editor.state, but the view keeps its own
// tree that the background parser advances lazily. Extensions that build decorations from
// syntaxTree(view.state) (e.g. makeInlineReplaceExtension) can then run against an incomplete
// tree and skip their decorations, so force the view's own tree to complete before returning.
forceParsing(editor, editor.state.doc.length);
return editor;
};
+2 -2
View File
@@ -37,8 +37,8 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/lint": "6.9.5",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.6",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.42.1",
"@joplin/fork-uslug": "^2.0.0",
"@lezer/common": "1.5.0",
"@lezer/highlight": "1.2.3",
-5
View File
@@ -73,7 +73,6 @@ import getAppName from './getAppName';
import PerformanceLogger from './PerformanceLogger';
import Synchronizer from './Synchronizer';
import NoteLockKey from './services/noteLock/NoteLockKey';
import isNoteLockEnabled from './services/noteLock/isNoteLockEnabled';
import NoteLockSession from './services/noteLock/NoteLockSession';
import NoteLockService from './services/noteLock/NoteLockService';
@@ -411,10 +410,6 @@ export default class BaseApplication {
'syncInfoCache': async () => {
appLogger.info('"syncInfoCache" was changed - setting up encryption related code');
// The note lock session only detects a synced key change lazily; polling here locks
// it (and notifies the UI) as soon as the change arrives.
if (isNoteLockEnabled()) NoteLockSession.instance().isUnlocked();
await loadMasterKeysFromSettings(EncryptionService.instance());
const loadedMasterKeyIds = EncryptionService.instance().loadedMasterKeyIds();
-22
View File
@@ -13,28 +13,6 @@ describe('callbackUrlUtils', () => {
expect(callbackUrlUtils.isCallbackUrl('joplin://x-callback-url/invalidCommand?a=b')).toBe(false);
});
it('should identify getCurrentNote and createNote callback urls', () => {
expect(callbackUrlUtils.isCallbackUrl('joplin://x-callback-url/getCurrentNote?x-success=hook://a')).toBe(true);
expect(callbackUrlUtils.isCallbackUrl('joplin://x-callback-url/createNote?title=Hello&x-success=hook://a')).toBe(true);
});
// The trailing "?" pins the command name so a prefix can't bypass validation. See 69826610a.
it('should reject callback urls whose command name is only a prefix match', () => {
expect(callbackUrlUtils.isCallbackUrl('joplin://x-callback-url/getCurrentNoteEvil?x-success=hook://a')).toBe(false);
expect(callbackUrlUtils.isCallbackUrl('joplin://x-callback-url/createNoteEvil?title=x')).toBe(false);
expect(callbackUrlUtils.isCallbackUrl('joplin://x-callback-url/openNoteEvil?id=x')).toBe(false);
});
it('should parse getCurrentNote and createNote callback urls', () => {
const getCurrent = callbackUrlUtils.parseCallbackUrl('joplin://x-callback-url/getCurrentNote?x-success=hook://a');
expect(getCurrent.command).toBe(callbackUrlUtils.CallbackUrlCommand.GetCurrentNote);
expect(getCurrent.params).toStrictEqual({ 'x-success': 'hook://a' });
const create = callbackUrlUtils.parseCallbackUrl('joplin://x-callback-url/createNote?title=Hello&x-success=hook://a');
expect(create.command).toBe(callbackUrlUtils.CallbackUrlCommand.CreateNote);
expect(create.params).toStrictEqual({ title: 'Hello', 'x-success': 'hook://a' });
});
it('should build valid note callback urls', () => {
const noteUrl = callbackUrlUtils.getNoteCallbackUrl('123456');
expect(callbackUrlUtils.isCallbackUrl(noteUrl)).toBe(true);
+1 -5
View File
@@ -3,9 +3,7 @@ const URL = require('url-parse');
export function isCallbackUrl(s: string) {
return s.startsWith('joplin://x-callback-url/openNote?') ||
s.startsWith('joplin://x-callback-url/openFolder?') ||
s.startsWith('joplin://x-callback-url/openTag?') ||
s.startsWith('joplin://x-callback-url/getCurrentNote?') ||
s.startsWith('joplin://x-callback-url/createNote?');
s.startsWith('joplin://x-callback-url/openTag?');
}
export function getNoteCallbackUrl(noteId: string) {
@@ -24,8 +22,6 @@ export const enum CallbackUrlCommand {
OpenNote = 'openNote',
OpenFolder = 'openFolder',
OpenTag = 'openTag',
GetCurrentNote = 'getCurrentNote',
CreateNote = 'createNote',
}
export interface CallbackUrlInfo {
+1 -1
View File
@@ -23,6 +23,6 @@ export const runtime = (): CommandRuntime => {
},
});
},
enabledCondition: '!noteIsReadOnlyShare && !inTrash && someNotesSelected',
enabledCondition: '!noteIsReadOnly && !inTrash && someNotesSelected',
};
};
@@ -28,6 +28,6 @@ export const runtime = (): CommandRuntime => {
await Note.batchDelete(noteIds, { toTrash: false, sourceDescription: 'permanentlyDeleteNote command' });
}
},
enabledCondition: '(!noteIsReadOnlyShare || inTrash) && someNotesSelected',
enabledCondition: '(!noteIsReadOnly || inTrash) && someNotesSelected',
};
};
@@ -97,7 +97,7 @@ interface Shared {
installResourceHandling?: (refreshResourceHandler: ResourceHandler)=> void;
uninstallResourceHandling?: (refreshResourceHandler: ResourceHandler)=> void;
reloadNote?: (comp: BaseNoteScreenComponent, useDefaultEditorState?: boolean)=> Promise<NoteEntity>;
reloadNote?: (comp: BaseNoteScreenComponent)=> Promise<NoteEntity>;
}
const shared: Shared = {};
@@ -295,21 +295,18 @@ shared.isModified = function(comp: BaseNoteScreenComponent) {
return !!Object.getOwnPropertyNames(diff).length;
};
shared.reloadNote = async (comp: BaseNoteScreenComponent, useDefaultEditorState = false) => {
shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
const isProvisionalNote = comp.props.provisionalNoteIds.includes(comp.props.noteId);
const note = await Note.load(comp.props.noteId);
let mode = comp.state.mode;
if (useDefaultEditorState) {
const panes = comp.props.noteVisiblePanes;
mode = panes.includes('editor') ? 'edit' : 'view';
const panes = comp.props.noteVisiblePanes;
let mode = panes.includes('editor') ? 'edit' : 'view';
// Override the mode if the default state is not last
const defaultState = Setting.value('editor.mobile.defaultEditState');
if (defaultState === 'view') mode = 'view';
if (defaultState === 'edit') mode = 'edit';
}
// Override the mode if the default state is not last
const defaultState = Setting.value('editor.mobile.defaultEditState');
if (defaultState === 'view') mode = 'view';
if (defaultState === 'edit') mode = 'edit';
// Prevent trashed notes and notes created via sharing from opening in edit mode.
if (note?.deleted_time || comp.props.sharedData) {
@@ -357,7 +354,7 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent, useDefaultEditorState
};
shared.initState = async function(comp: BaseNoteScreenComponent) {
const note = await shared.reloadNote(comp, true);
const note = await shared.reloadNote(comp);
// Ensure that only empty notes created for shared content are populated with sharedData, because in some cases
// existing notes can be overwritten by the shared data. See https://github.com/laurent22/joplin/issues/11479
-13
View File
@@ -21,8 +21,6 @@ export enum EventName {
NoteResourceIndexed = 'noteResourceIndexed',
WindowOpen = 'windowOpen',
WindowClose = 'windowClose',
NoteLockSessionChange = 'noteLockSessionChange',
NoteLockNoteStateChange = 'noteLockNoteStateChange',
}
export interface ItemChangeEvent {
@@ -68,15 +66,6 @@ export interface WindowCloseEvent {
windowId: string;
}
export interface NoteLockSessionChangeEvent {
unlocked: boolean;
}
export interface NoteLockNoteStateChangeEvent {
noteId: string;
isLocked: boolean;
}
type EventArgs = {
[EventName.ResourceCreate]: [];
[EventName.ResourceChange]: [ResourceChangeEvent];
@@ -94,8 +83,6 @@ type EventArgs = {
[EventName.NoteResourceIndexed]: [];
[EventName.WindowOpen]: [WindowOpenEvent];
[EventName.WindowClose]: [WindowCloseEvent];
[EventName.NoteLockSessionChange]: [NoteLockSessionChangeEvent];
[EventName.NoteLockNoteStateChange]: [NoteLockNoteStateChangeEvent];
};
type EventListenerCallbacks = {
+6 -59
View File
@@ -290,7 +290,6 @@ describe('models/Note', () => {
}, { useNoteLock: true });
const storedNote = await Note.load(note.id);
expect(note.body).toBe(plainTextBody);
expect(storedNote.body).not.toBe(plainTextBody);
expect(storedNote.extracted_resource_ids).toBe(`${resourceId1},${resourceId2}`);
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(plainTextBody);
@@ -306,10 +305,6 @@ describe('models/Note', () => {
id: note.id,
body: 'must not be stored',
}, { useNoteLock: true })).rejects.toThrow('Gated note lock save is missing lock state');
await expect(Note.save({
id: note.id,
is_locked: 1,
}, { useNoteLock: true })).rejects.toThrow('Gated note lock save is missing body');
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(plainTextBody);
await Note.save({
@@ -328,66 +323,16 @@ describe('models/Note', () => {
expect(unlockedNote.extracted_resource_ids).toBe('');
});
it('should treat a locked note as a normal note while the feature is disabled', async () => {
it('should not decrypt locked notes while the feature is disabled', async () => {
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
const note = await Note.save({
body: 'secret',
is_locked: 1,
}, { useNoteLock: true });
const cipherText = (await Note.load(note.id)).body;
Setting.setValue('featureFlag.noteLock', false);
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(cipherText);
await Note.save({ id: note.id, is_locked: 1, body: 'overwrite' });
expect((await Note.load(note.id)).body).toBe('overwrite');
});
it('should encrypt a gated save with a captured key while the session is locked, but not after a key rotation', async () => {
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
const note = await Note.save({ body: 'secret', is_locked: 1 }, { useNoteLock: true });
const capturedKey = NoteLockSession.instance().decryptedKey();
NoteLockSession.instance().lock();
const updatedNote = { ...await Note.load(note.id), body: 'updated', isDecrypted: true };
await Note.save(updatedNote, { useNoteLock: true, noteLockKey: capturedKey });
await NoteLockSession.instance().unlock('123456');
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe('updated');
await NoteLockSession.instance().reset('654321');
const bodyBeforeStaleAttempt = (await Note.load(note.id)).body;
const staleNote = { ...await Note.load(note.id), body: 'stale', isDecrypted: true };
await expect(Note.save(staleNote, { useNoteLock: true, noteLockKey: capturedKey })).rejects.toThrow('Note lock key changed during operation');
expect((await Note.load(note.id)).body).toBe(bodyBeforeStaleAttempt);
});
it('should refuse a gated save of a locked note whose body does not come from a gated load', async () => {
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
const note = await Note.save({ body: 'secret', is_locked: 1 }, { useNoteLock: true });
expect(note).toMatchObject({ isDecrypted: true });
expect(await Note.load(note.id, { useNoteLock: true })).toMatchObject({ isDecrypted: true });
expect('isDecrypted' in await Note.load(note.id)).toBe(false);
// The ungated load returned cipher text, so saving it back through the gated path would
// encrypt it a second time.
await expect(Note.save({ ...await Note.load(note.id) }, { useNoteLock: true })).rejects.toThrow('Gated note lock save is missing decrypted state');
const saved = await Note.save({ ...await Note.load(note.id, { useNoteLock: true }), body: 'edited' }, { useNoteLock: true });
expect(saved).toMatchObject({ isDecrypted: true, body: 'edited' });
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe('edited');
// The editor saves partial notes without share_id, which makes the dispatch path reload the
// note with a field list built from the saved note's keys - the marker must not leak into it.
const editorShapedNote = { id: note.id, deleted_time: 0, title: 'edited', body: 'edited again', is_locked: 1, isDecrypted: true };
const editorShaped = await Note.save(editorShapedNote, { useNoteLock: true });
expect(editorShaped).toMatchObject({ isDecrypted: true, body: 'edited again' });
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe('edited again');
const unlocked = await Note.save({ ...await Note.load(note.id, { useNoteLock: true }), is_locked: 0 }, { useNoteLock: true });
expect(unlocked).toMatchObject({ isDecrypted: false });
expect((await Note.load(note.id, { useNoteLock: true })).body).toBe(note.body);
});
it('should fail closed when note lock encryption cannot decrypt or encrypt', async () => {
@@ -433,8 +378,10 @@ describe('models/Note', () => {
await NoteLockKey.instance().create('123456');
await NoteLockSession.instance().unlock('123456');
const lockedNote = { ...await Note.load(note.id), is_locked: 1, isDecrypted: true };
await Note.save(lockedNote, { useNoteLock: true });
await Note.save({
...await Note.load(note.id),
is_locked: 1,
}, { useNoteLock: true });
expect(await Revision.countRevisions(Note.modelType(), note.id)).toBe(1);
expect(await Revision.load(encryptedRevision.id)).toBeTruthy();
+2 -20
View File
@@ -848,15 +848,9 @@ export default class Note extends BaseItem {
// we should set beforeNoteJson to the current contents in the database, or the last value which was stored
// in the item_changes table
const oldNote = !isNew && o.id ? await Note.load(o.id) : null;
let plainTextBodyToReturn: string = null;
if (isNoteLockEnabled() && !!options?.useNoteLock) {
// Callers use the returned note to update UI state, so it must carry the plaintext
// body even though the encrypted one is what gets persisted.
if (NoteLockNote.isLocked(o) && 'body' in o) plainTextBodyToReturn = o.body;
await NoteLockNote.prepareForSave(o, this.linkedItemIds, this.serializeExtractedResourceIds, isNew, options.noteLockKey);
await NoteLockNote.prepareForSave(o, this.linkedItemIds, this.serializeExtractedResourceIds, isNew);
}
// Cleared for ungated saves too, in case a gated load is fed to an ungated save.
delete (o as Record<string, unknown>).isDecrypted;
syncDebugLog.info('Save Note: P:', oldNote);
@@ -865,10 +859,7 @@ export default class Note extends BaseItem {
// has just been downloaded from the sync target and save is invoked when the note has not yet been decrypted
if (oldNote && !oldNote.encryption_applied) {
const changedSinceCollection = this.revisionService().changedSinceCollection(o.id);
// If the note is transitioning from is_locked 0 > 1, clear the beforeNoteJson to avoid creating
// an unencrypted revision. After the transition has taken place, it is fine to populate it,
// because the oldNote is an ungated full load which will include the data encrypted when locked
if (isNoteLockEnabled() && NoteLockNote.isLocked(o) && !NoteLockNote.isLocked(oldNote)) {
if (isNoteLockEnabled() && NoteLockNote.isLocked(o)) {
beforeNoteJson = null;
} else if (changedSinceCollection) {
beforeNoteJson = await ItemChange.oldNoteContent(o.id);
@@ -936,15 +927,6 @@ export default class Note extends BaseItem {
});
}
if (isNoteLockEnabled() && !!options?.useNoteLock) {
const gatedResult = {
...savedNote,
body: plainTextBodyToReturn !== null ? plainTextBodyToReturn : savedNote.body,
isDecrypted: NoteLockNote.isLocked(o),
};
savedNote = gatedResult;
}
return savedNote;
}
+6 -11
View File
@@ -164,7 +164,7 @@ const globalMigrations: GlobalMigration[] = [
interface UserSettingMigration {
oldName: string;
newName: string;
transformValue: (value: unknown)=> unknown;
transformValue: (value: string)=> string | string[];
// Currently the migration code only supports migrating a plugin setting to the regular settings
// (not a plugin setting to a different name). So "oldName" should be the plugin setting name
@@ -182,13 +182,13 @@ const userSettingMigration: UserSettingMigration[] = [
{
oldName: 'spellChecker.language',
newName: 'spellChecker.languages',
transformValue: (value) => { return [value]; },
transformValue: (value: string) => { return [value]; },
isPluginSetting: false,
},
{
oldName: 'plugin-org.joplinapp.plugins.AbcSheetMusic.options',
newName: 'markdown.plugin.abc.options',
transformValue: (value) => { return value; },
transformValue: (value: string) => { return value; },
isPluginSetting: true,
},
];
@@ -1294,7 +1294,6 @@ class Setting extends BaseModel {
'noteLock',
'joplinCloud',
'ai',
'ai.tools',
'mcp',
'editor',
'plugins',
@@ -1315,7 +1314,7 @@ class Setting extends BaseModel {
}
public static isSubSection(sectionName: string) {
return ['encryption', 'application', 'appearance', 'joplinCloud', 'ai.tools'].includes(sectionName);
return ['encryption', 'application', 'appearance', 'joplinCloud'].includes(sectionName);
}
public static groupMetadatasBySections(metadatas: SettingItem[]): MetadataBySection {
@@ -1372,7 +1371,7 @@ class Setting extends BaseModel {
if (name === 'importOrExport') return _('Import and Export');
if (name === 'moreInfo') return _('More information');
if (name === 'ai') return _('AI');
if (name === 'ai.tools') return _('Tools');
if (name === 'mcp') return _('MCP Server');
if (this.customSections_[name] && this.customSections_[name].label) return this.customSections_[name].label;
@@ -1389,9 +1388,6 @@ class Setting extends BaseModel {
if (name === 'noteLock') {
return _('Locked notes are encrypted on this device and can only be read after entering your note lock password. The password is required again after locking or restarting Joplin.');
}
if (name === 'ai.tools') {
return _('Tools and services to expose to AI. AI agents can use these tools either via the note chat panel or Joplin\'s MCP server (if enabled).');
}
if (this.customSections_[name] && this.customSections_[name].description) return this.customSections_[name].description;
@@ -1404,7 +1400,6 @@ class Setting extends BaseModel {
'general': _('Language, date format'),
'appearance': _('Themes, notebook sort order'),
'sync': _('Sync, encryption, proxy'),
'noteLock': _('Note lock password, auto lock'),
'joplinCloud': _('Email To Note, login information'),
'editor': _('Typography, spellcheck, layout'),
'markdownPlugins': _('Media player, math, diagrams, table of contents'),
@@ -1458,7 +1453,7 @@ class Setting extends BaseModel {
'importOrExport': 'fa fa-file-export',
'moreInfo': 'fa fa-info-circle',
'ai': 'fa fa-robot',
'ai.tools': 'fa fa-plug',
'mcp': 'fa fa-plug',
};
// Icomoon icons are currently not present in the mobile app -- we override these
+55 -81
View File
@@ -26,10 +26,6 @@ const show3rdPartySyncSettings = (Setting: typeof SettingType) => {
return !Setting.value('isJoplinCloudWebApp');
};
const showAiTools = (settings: Record<string, unknown>) => {
return !!settings['mcp.enabled'] || !!settings['ai.enabled'];
};
export enum CameraDirection {
Back,
Front,
@@ -640,17 +636,6 @@ const builtInMetadata = (Setting: typeof SettingType) => {
label: () => _('OCR: Search in extracted content'),
},
'mcp.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'ai',
appTypes: [AppType.Desktop],
label: () => _('Enable MCP server'),
description: () => _('Exposes Joplin notes to external AI applications (Claude Desktop, Cursor, etc.) via the Model Context Protocol. Requires the Web Clipper service to be running. Connected AI tools can read your note content; any text they retrieve may be sent to the external LLM provider those tools use.'),
storage: SettingStorage.File,
},
'ai.enabled': {
value: false,
type: SettingItemType.Bool,
@@ -867,125 +852,124 @@ const builtInMetadata = (Setting: typeof SettingType) => {
storage: SettingStorage.Database,
},
'ai.tool.edit_current.enabled': {
'mcp.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'mcp',
appTypes: [AppType.Desktop],
label: () => _('Enable MCP server'),
description: () => _('Exposes Joplin notes to external AI applications (Claude Desktop, Cursor, etc.) via the Model Context Protocol. Requires the Web Clipper service to be running. Connected AI tools can read your note content; any text they retrieve may be sent to the external LLM provider those tools use.'),
storage: SettingStorage.File,
},
'mcp.tool.search_notes.enabled': {
value: true,
type: SettingItemType.Bool,
public: true,
section: 'ai.tools',
section: 'mcp',
appTypes: [AppType.Desktop],
show: showAiTools,
label: () => _('Allow editing the current note (chat panel only)'),
description: () => _('Enables the default edit operations for notes with an open AI chat panel. When disabled, the chat panel can still read the open note. Does not apply to MCP.'),
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow searching notes'),
storage: SettingStorage.File,
},
'ai.tool.search_notes.enabled': {
value: false,
'mcp.tool.read_note.enabled': {
value: true,
type: SettingItemType.Bool,
public: true,
section: 'ai.tools',
section: 'mcp',
appTypes: [AppType.Desktop],
show: showAiTools,
label: () => _('Allow searching notes'),
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow reading notes'),
storage: SettingStorage.File,
},
'ai.tool.read_note.enabled': {
value: false,
'mcp.tool.list_notebooks.enabled': {
value: true,
type: SettingItemType.Bool,
public: true,
section: 'ai.tools',
section: 'mcp',
appTypes: [AppType.Desktop],
show: showAiTools,
label: () => _('Allow reading notes'),
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow listing notebooks'),
storage: SettingStorage.File,
},
'ai.tool.list_notebooks.enabled': {
value: false,
'mcp.tool.list_tags.enabled': {
value: true,
type: SettingItemType.Bool,
public: true,
section: 'ai.tools',
section: 'mcp',
appTypes: [AppType.Desktop],
show: showAiTools,
label: () => _('Allow listing notebooks'),
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow listing tags'),
storage: SettingStorage.File,
},
'ai.tool.list_tags.enabled': {
'mcp.tool.create_note.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'ai.tools',
section: 'mcp',
appTypes: [AppType.Desktop],
show: showAiTools,
label: () => _('Allow listing tags'),
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow creating notes'),
storage: SettingStorage.File,
},
'ai.tool.create_note.enabled': {
'mcp.tool.update_note.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'ai.tools',
section: 'mcp',
appTypes: [AppType.Desktop],
show: showAiTools,
label: () => _('Allow creating notes'),
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow updating notes'),
storage: SettingStorage.File,
},
'ai.tool.update_note.enabled': {
'mcp.tool.delete_note.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'ai.tools',
section: 'mcp',
appTypes: [AppType.Desktop],
show: showAiTools,
label: () => _('Allow updating notes'),
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow trashing notes'),
storage: SettingStorage.File,
},
'ai.tool.delete_note.enabled': {
'mcp.tool.manage_tags.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'ai.tools',
section: 'mcp',
appTypes: [AppType.Desktop],
show: showAiTools,
label: () => _('Allow trashing notes'),
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow editing tags on notes'),
storage: SettingStorage.File,
},
'ai.tool.manage_tags.enabled': {
'mcp.tool.create_notebook.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'ai.tools',
section: 'mcp',
appTypes: [AppType.Desktop],
show: showAiTools,
label: () => _('Allow editing tags on notes'),
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow creating notebooks'),
storage: SettingStorage.File,
},
'ai.tool.create_notebook.enabled': {
value: false,
'mcp.tool.semantic_search_notes.enabled': {
value: true,
type: SettingItemType.Bool,
public: true,
section: 'ai.tools',
section: 'mcp',
appTypes: [AppType.Desktop],
show: showAiTools,
label: () => _('Allow creating notebooks'),
storage: SettingStorage.File,
},
'ai.tool.semantic_search_notes.enabled': {
value: false,
type: SettingItemType.Bool,
public: true,
section: 'ai.tools',
appTypes: [AppType.Desktop],
show: showAiTools,
label: () => _('Allow semantic search of notes'),
show: (settings) => !!settings['mcp.enabled'],
label: () => _('MCP: Allow semantic search of notes'),
storage: SettingStorage.File,
},
@@ -2328,16 +2312,6 @@ const builtInMetadata = (Setting: typeof SettingType) => {
advanced: true,
},
// Controls the new conflict resolution UI. It is hidden and turned off by
// default so the feature can be developed over multiple releases without
// affecting users. Make it public and enable it by default once it is ready.
'featureFlag.conflictResolution': {
value: false,
type: SettingItemType.Bool,
public: false,
storage: SettingStorage.File,
isGlobal: true,
},
// 'featureFlag.syncAccurateTimestamps': {
// value: false,
-4
View File
@@ -1,5 +1,4 @@
import { SqlQuery } from '../../services/database/types';
import type { DecryptedNoteLockKey } from '../../services/noteLock/NoteLockKey';
export enum PaginationOrderDir {
ASC = 'ASC',
@@ -53,9 +52,6 @@ export interface SaveOptions {
dispatchOptions?: { preserveSelection: boolean };
disableReadOnlyCheck?: boolean;
useNoteLock?: boolean;
// Encrypt with this key captured when the note was decrypted, instead of the live session key.
// Lets a pending editor save complete after the session locks; a real key rotation still aborts it.
noteLockKey?: DecryptedNoteLockKey;
changeSource?: number;
-26
View File
@@ -1050,30 +1050,4 @@ describe('reducer', () => {
// The primary window should be unaffected
expect(state.selectedNoteIds).toEqual([notes[0].id]);
});
it.each([
['without a noteId', undefined, true],
['for the selected note', 0, true],
['for a different note', 1, false],
])('should request an editor reload %s', async (_, noteIndex, shouldReload) => {
jest.useFakeTimers();
const folders = await createNTestFolders(1);
const notes = await createNTestNotes(2, folders[0]);
let state = initTestState(folders, 0, notes, [0]);
const previousReloadTime = state.editorNoteReloadTimeRequest;
const now = Date.now();
state = reducer(state, {
type: 'EDITOR_NOTE_NEEDS_RELOAD',
noteId: noteIndex === undefined ? undefined : notes[noteIndex].id,
});
expect(state.editorNoteReloadTimeRequest).toBe(
shouldReload ? now : previousReloadTime,
);
jest.useRealTimers();
});
});
+1 -22
View File
@@ -101,9 +101,6 @@ export interface WindowState {
selectedNoteIds: string[];
selectedNoteHash: string;
// Only used to disable menu actions that update the active note in the editor. Bulk actions
// do not need to be disabled because they do not result in an error.
activeNoteIsUndecryptable: boolean;
selectedFolderId: string;
selectedFolderIds: string[];
selectedTagId: string;
@@ -128,7 +125,6 @@ export const defaultWindowState: WindowState = {
notesParentType: null,
selectedNoteIds: [],
selectedNoteHash: '',
activeNoteIsUndecryptable: false,
selectedFolderId: null,
selectedFolderIds: [],
selectedTagId: null,
@@ -186,7 +182,6 @@ export interface State extends WindowState {
editorNoteStatuses: EditorNoteStatuses;
isInsertingNotes: boolean;
hasEncryptedItems: boolean;
noteLockSessionUnlocked: boolean;
needApiAuth: boolean;
profileConfig: ProfileConfig;
noteListRendererIds: string[];
@@ -258,7 +253,6 @@ export const defaultState: State = {
editorNoteStatuses: {},
isInsertingNotes: false,
hasEncryptedItems: false,
noteLockSessionUnlocked: false,
needApiAuth: false,
profileConfig: null,
noteListRendererIds: getListRendererIds(),
@@ -1519,19 +1513,6 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
draft.hasDisabledEncryptionItems = action.value;
break;
case 'SET_NOTE_LOCK_SESSION_UNLOCKED':
draft.noteLockSessionUnlocked = action.value;
break;
case 'SET_ACTIVE_NOTE_IS_UNDECRYPTABLE':
{
// The editor reporting this may be in a background window, so the flag follows that
// window rather than whichever one currently has focus.
const windowDraft = stateUtils.windowStateById(draft, action.windowId ?? draft.windowId);
if (windowDraft) windowDraft.activeNoteIsUndecryptable = action.value;
}
break;
case 'CLIPPER_SERVER_SET':
{
const clipperServer = { ...draft.clipperServer };
@@ -1617,9 +1598,7 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
case 'EDITOR_NOTE_NEEDS_RELOAD':
{
if (!action.noteId || (draft.selectedNoteIds.length && draft.selectedNoteIds[0] === action.noteId)) {
draft.editorNoteReloadTimeRequest = Date.now();
}
draft.editorNoteReloadTimeRequest = Date.now();
}
break;
@@ -1,5 +1,5 @@
import type { EditOp } from '../buildEditorTools';
import findFencedBlock from '../../utils/findFencedBlock';
import type { EditOp } from './noteChat';
import findFencedBlock from './utils/findFencedBlock';
export type ApplyEditStatus = 'applied' | 'anchor-not-found' | 'invalid';
+168 -81
View File
@@ -1,21 +1,16 @@
import { _internal, ChatCommands, runNoteChat } from './noteChat';
import { _internal, ChatCommands, NoteContext, runNoteChat } from './noteChat';
import { applyAnchorEdits } from './applyNoteEdits';
import { ChatRole, ChatToolCall } from './types';
import { expectThrow, setupDatabase, switchClient, withWarningSilenced } from '../../testing/test-utils';
import { expectThrow, setupDatabase, switchClient } from '../../testing/test-utils';
import Setting from '../../models/Setting';
import { NoteContext } from './tools/types';
import ToolIndex from './tools/ToolIndex';
import { isEditorToolCall } from './tools/buildEditorTools';
const makeTestContext = (defaultContext: Partial<NoteContext>) => {
const makeTestContext = () => {
let body = 'Body';
const initialContext: NoteContext = {
title: 'Test',
body: 'Body',
noteId: '00000000000000000000000000000000',
folderId: '00000000000000000000000000000001',
body,
selection: null,
...defaultContext,
};
let body = initialContext.body;
const commands: ChatCommands = {
replaceSelection: jest.fn(),
@@ -26,11 +21,8 @@ const makeTestContext = (defaultContext: Partial<NoteContext>) => {
displayError: jest.fn(),
};
const context = () => ({ ...initialContext, body });
return {
onContext: () => Promise.resolve(context()),
get context() { return context(); },
onContext: () => Promise.resolve({ ...initialContext, body }),
commands,
};
};
@@ -48,8 +40,6 @@ describe('noteChat', () => {
title: 'My note',
body: 'long body text',
selection: 'just this bit',
noteId: '',
folderId: '',
});
expect(prompt).toContain('My note');
expect(prompt).toContain('just this bit');
@@ -58,7 +48,7 @@ describe('noteChat', () => {
});
test('systemPrompt advertises Joplin-specific Markdown features', () => {
const prompt = _internal.systemPrompt({ title: 'n', body: 'b', noteId: '', folderId: '', selection: null });
const prompt = _internal.systemPrompt({ title: 'n', body: 'b', selection: null });
// The fence tags are the load-bearing strings — the model knows the
// syntax inside (JSONCanvas, Mermaid, KaTeX) but needs the Joplin
// fence tag to wrap it correctly.
@@ -72,16 +62,14 @@ describe('noteChat', () => {
expect(prompt).toContain('- [ ]');
});
test('systemPrompt should not include body', () => {
test('systemPrompt includes full body when no selection', () => {
const prompt = _internal.systemPrompt({
title: 'My note',
body: 'the whole body',
noteId: '',
folderId: '',
selection: null,
});
// Including the body in the prompt would cause a cache invalidation on each note change
expect(prompt).not.toContain('the whole body');
expect(prompt).toContain('the whole body');
expect(prompt).toContain('BEGIN NOTE');
});
test.each([
@@ -101,7 +89,7 @@ describe('noteChat', () => {
body: 'b',
selection: null,
},
expectedOperations: ['insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'readNoteBody'],
expectedOperations: ['insertBefore', 'insertAfter', 'appendToNote', 'replaceRange'],
},
{
label: 'offers replaceFencedBlock when Mermaid block present',
@@ -110,16 +98,13 @@ describe('noteChat', () => {
body: '```mermaid\ngitGraph\n\tcommit\n```\n',
selection: null,
},
expectedOperations: ['insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'replaceFencedBlock', 'readNoteBody'],
expectedOperations: ['insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'replaceFencedBlock'],
},
])('toolDefinitions should include the expected operations (case $label)', ({ note, expectedOperations }) => {
const { commands, context } = makeTestContext(note);
const editSchemaItems = new ToolIndex({ note: context, commands }).getTools();
const editSchemaItems = _internal.toolDefinitions(note);
const allowedSchemaOperations = editSchemaItems.map(item => item.id).sort();
expect(
allowedSchemaOperations.filter(id => isEditorToolCall(id)),
).toEqual([...expectedOperations].map(o => `editor.${o}`).sort());
const allowedSchemaOperations = editSchemaItems.map(item => item.name).sort();
expect(allowedSchemaOperations).toEqual([...expectedOperations].sort());
});
test('estimateTokens approximates char/4', () => {
@@ -127,10 +112,130 @@ describe('noteChat', () => {
expect(_internal.estimateTokens('a'.repeat(400))).toBe(100);
});
test('applyAnchorEdits appends with paragraph break (covered in detail below)', () => {
const { newBody } = applyAnchorEdits('hello', [
{ op: 'appendToNote', text: 'world' },
], 0);
expect(newBody).toBe('hello\n\nworld');
});
test('applyAnchorEdits inserts before/after anchor', () => {
const before = applyAnchorEdits('one two three', [
{ op: 'insertBefore', anchor: 'two', text: 'X ' },
], 0);
expect(before.newBody).toBe('one X two three');
const after = applyAnchorEdits('one two three', [
{ op: 'insertAfter', anchor: 'two', text: ' X' },
], 0);
expect(after.newBody).toBe('one two X three');
});
test('applyAnchorEdits replaces range and reports missing anchor', () => {
const replaced = applyAnchorEdits('alpha beta gamma', [
{ op: 'replaceRange', anchor: 'beta', text: 'BETA' },
], 0);
expect(replaced.newBody).toBe('alpha BETA gamma');
const missing = applyAnchorEdits('alpha beta gamma', [
{ op: 'replaceRange', anchor: 'delta', text: 'X' },
], 0);
expect(missing.newBody).toBe('alpha beta gamma');
expect(missing.appliedEdits[0].status).toBe('anchor-not-found');
});
test('applyAnchorEdits picks anchor closest to cursor on duplicates', () => {
// "foo" appears at index 0 and index 8. Cursor near second occurrence.
const body = 'foo bar foo baz';
const { newBody } = applyAnchorEdits(body, [
{ op: 'insertAfter', anchor: 'foo', text: '!' },
], 10);
expect(newBody).toBe('foo bar foo! baz');
});
test('applyAnchorEdits appends with blank line for Markdown paragraph break', () => {
// Empty body — no separator needed.
expect(applyAnchorEdits('', [{ op: 'appendToNote', text: 'first' }], 0).newBody).toBe('first');
// No trailing newline — needs full \n\n.
expect(applyAnchorEdits('hello', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
// One trailing newline — add one more to make a blank line.
expect(applyAnchorEdits('hello\n', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
// Already has blank line — no extra separator.
expect(applyAnchorEdits('hello\n\n', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
});
test('applyAnchorEdits rejects invalid edits without mutating the body', () => {
// Empty anchor, missing text, wrong shape, unknown op.
const body = 'hello world';
const cases = [
{ op: 'insertBefore', anchor: '', text: 'X' },
{ op: 'insertAfter', anchor: 'world' }, // missing text
{ op: 'appendToNote' }, // missing text
{ op: 'mysteryOp', text: 'X' },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = applyAnchorEdits(body, cases as any, 0);
expect(result.newBody).toBe(body);
expect(result.appliedEdits.every(e => e.status === 'invalid')).toBe(true);
});
test('applyAnchorEdits replaces the inner content of a fenced block', () => {
const body = 'Intro paragraph.\n\n```jsoncanvas\n{"nodes":[],"edges":[]}\n```\n\nOutro.';
const { newBody, appliedEdits } = applyAnchorEdits(body, [
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"nodes":[{"id":"a"}],"edges":[]}' },
], 0);
expect(appliedEdits[0].status).toBe('applied');
expect(newBody).toContain('```jsoncanvas\n{"nodes":[{"id":"a"}],"edges":[]}\n```');
// Surrounding prose is preserved.
expect(newBody).toContain('Intro paragraph.');
expect(newBody).toContain('Outro.');
});
test('applyAnchorEdits picks the fenced block closest to the cursor', () => {
const first = '```jsoncanvas\n{"v":1}\n```';
const second = '```jsoncanvas\n{"v":2}\n```';
const body = `${first}\n\nSome prose between.\n\n${second}`;
// Cursor near the second block — should edit that one, leaving the first untouched.
const cursorNearSecond = body.indexOf(second) + 5;
const { newBody } = applyAnchorEdits(body, [
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"v":99}' },
], cursorNearSecond);
expect(newBody).toContain('{"v":1}'); // first untouched
expect(newBody).toContain('{"v":99}'); // second replaced
expect(newBody).not.toContain('{"v":2}');
// Cursor at 0 — first block wins (fallback behaviour preserved).
const { newBody: firstWins } = applyAnchorEdits(body, [
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"v":0}' },
], 0);
expect(firstWins).toContain('{"v":0}');
expect(firstWins).toContain('{"v":2}'); // second untouched
expect(firstWins).not.toContain('{"v":1}');
});
test('applyAnchorEdits reports anchor-not-found when fenced block missing', () => {
const body = 'No whiteboard in this note.';
const { newBody, appliedEdits } = applyAnchorEdits(body, [
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{}' },
], 0);
expect(newBody).toBe(body);
expect(appliedEdits[0].status).toBe('anchor-not-found');
});
test('applyAnchorEdits rejects replaceFencedBlock with unsupported tag', () => {
// Plain code fences (```js, ```python, ...) are not structured-document
// formats — the model should use anchor-based ops instead.
const body = '```js\nconsole.log("x");\n```';
const { newBody, appliedEdits } = applyAnchorEdits(body, [
{ op: 'replaceFencedBlock', tag: 'js', text: 'console.log("y");' },
], 0);
expect(newBody).toBe(body);
expect(appliedEdits[0].status).toBe('invalid');
});
test('toolDefinitions advertises replaceFencedBlock with structured-block guidance', () => {
const { context, commands } = makeTestContext({ title: 'n', body: '```abc\n```', selection: null });
const tools = new ToolIndex({ note: context, commands }).getTools();
const toolDefinition = tools.find(tool => tool.id === 'editor.replaceFencedBlock');
const tools = _internal.toolDefinitions({ title: 'n', body: '```abc\n```', selection: null });
const toolDefinition = tools.find(tool => tool.name === 'replaceFencedBlock');
expect(toolDefinition).toBeTruthy();
expect(toolDefinition.description).toContain('jsoncanvas');
// Guidance to use the op for structured blocks, appendToNote for creation.
@@ -138,6 +243,16 @@ describe('noteChat', () => {
expect(toolDefinition.description).toContain('Use appendToNote');
});
test('applyAnchorEdits refuses replaceRange anchor covering most of the body', () => {
const body = 'short text';
// Anchor is >50% of body — would be destructive; refuse.
const { newBody, appliedEdits } = applyAnchorEdits(body, [
{ op: 'replaceRange', anchor: 'short text', text: '' },
], 0);
expect(newBody).toBe(body);
expect(appliedEdits[0].status).toBe('invalid');
});
test('assertWithinTokenBudget should include tool calls in the token budget', async () => {
_internal.assertWithinTokenBudget([
{ role: ChatRole.Assistant, content: 'Testing... '.repeat(5) },
@@ -149,7 +264,7 @@ describe('noteChat', () => {
role: ChatRole.Assistant,
content: 'Testing...'.repeat(5),
toolCalls: [{
toolName: 'editor.replaceSelection',
toolName: 'replaceSelection',
callId: 'call-1',
arguments: { text: 'Testing... '.repeat(50) },
parseError: null,
@@ -161,11 +276,11 @@ describe('noteChat', () => {
test('runTools should describe successful edit operations', async () => {
const toolCalls: ChatToolCall[] = [
{ toolName: 'editor.appendToNote', callId: 'call-1', arguments: { text: 'Test.' }, parseError: null },
{ toolName: 'editor.replaceRange', callId: 'call-2', arguments: { anchor: 'Body', text: 'Updated' }, parseError: null },
{ toolName: 'appendToNote', callId: 'call-1', arguments: { text: 'Test.' }, parseError: null },
{ toolName: 'replaceRange', callId: 'call-2', arguments: { anchor: 'Body', text: 'Updated' }, parseError: null },
];
const { onContext, commands } = makeTestContext({});
const { onContext, commands } = makeTestContext();
const result = await _internal.runTools(
{ text: 'Test...', toolCalls, usage: { inputTokens: 10, outputTokens: 300 } },
@@ -179,14 +294,14 @@ describe('noteChat', () => {
expect(result).toMatchObject([
{
role: ChatRole.Tool,
toolName: 'editor.appendToNote',
toolName: 'appendToNote',
toolCallId: 'call-1',
isError: false,
userDescription: 'Added 1 word',
},
{
role: ChatRole.Tool,
toolName: 'editor.replaceRange',
toolName: 'replaceRange',
toolCallId: 'call-2',
isError: false,
userDescription: 'Removed 1 word\nAdded 1 word',
@@ -195,34 +310,31 @@ describe('noteChat', () => {
});
test('noteChat should stop retry loop if several responses in a row include tool failures', async () => {
const { onContext, commands } = makeTestContext({});
const { onContext, commands } = makeTestContext();
const failingAndSucceedingToolCall = (repeat: number) => [
`/repeat ${repeat}`,
// one failing tool
`/tool editor.replaceRange ${JSON.stringify({ anchor: 'does not exist', text: 'replaced' })}`,
`/tool replaceRange ${JSON.stringify({ anchor: 'does not exist', text: 'replaced' })}`,
// and one tool call that should succeed
'/tool editor.appendToNote { "text": "test" }',
'/tool appendToNote { "text": "test" }',
].join('\n');
const userMessage = failingAndSucceedingToolCall(32);
const result = await withWarningSilenced(
/call editor.replaceRange failed/,
() => runNoteChat(
onContext,
[],
userMessage,
commands,
() => {},
new AbortController().signal,
),
const result = await runNoteChat(
onContext,
[],
userMessage,
commands,
() => {},
new AbortController().signal,
);
const failedAttempts = [];
for (let i = 0; i < 5; i++) {
failedAttempts.push(
{ role: ChatRole.Assistant },
{ role: ChatRole.Tool, isError: true, toolName: 'editor.replaceRange' },
{ role: ChatRole.Tool, isError: false, toolName: 'editor.appendToNote' },
{ role: ChatRole.Tool, isError: true, toolName: 'replaceRange' },
{ role: ChatRole.Tool, isError: false, toolName: 'appendToNote' },
);
}
@@ -230,34 +342,9 @@ describe('noteChat', () => {
{ role: ChatRole.System },
{ role: ChatRole.User, content: userMessage },
// Reading the body
{ role: ChatRole.Assistant },
{ role: ChatRole.Tool },
...failedAttempts,
{ role: ChatRole.Assistant, content: 'tool not found: editor.replaceRange\ntool not found: editor.appendToNote' },
]);
});
test('noteChat initialize the chat history with the note body', async () => {
const { onContext, commands } = makeTestContext({});
const result = await runNoteChat(
onContext,
[],
'test',
commands,
() => {},
new AbortController().signal,
);
expect(result).toMatchObject([
{ role: ChatRole.System },
{ role: ChatRole.User, content: 'test' },
{ role: ChatRole.Assistant, content: '', toolCalls: [{ toolName: 'editor.readNoteBody' }] },
{ role: ChatRole.Tool, content: 'Body', toolName: 'editor.readNoteBody' },
{ role: ChatRole.Assistant, content: '' },
{ role: ChatRole.Assistant, content: 'tool not found: replaceRange\ntool not found: appendToNote' },
]);
});
});
@@ -1,94 +0,0 @@
import { runNoteChat } from './noteChat';
import { ChatMessage, ChatRole } from './types';
import { setupDatabase, switchClient } from '../../testing/test-utils';
import Setting from '../../models/Setting';
import Note from '../../models/Note';
import Tag from '../../models/Tag';
import { NoteEntity } from '../database/types';
import { NoteContext } from './tools/types';
const runChatForNote = (note: NoteEntity, history: ChatMessage[], message: string) => {
const getContext = async () => {
const currentNote = await Note.load(note.id);
return <NoteContext> {
title: currentNote.title,
body: currentNote.body,
selection: null,
noteId: note.id,
folderId: note.parent_id,
};
};
return runNoteChat(
getContext,
history,
message,
{
replaceSelection: jest.fn(),
updateNoteBody: async (newBody: string) => {
await Note.save({ id: note.id, body: newBody });
},
displayError: jest.fn(),
},
jest.fn(),
new AbortController().signal,
);
};
describe('noteChat.tools', () => {
beforeEach(async () => {
await setupDatabase(0);
await switchClient(0);
Setting.setValue('ai.chat.providerType', 'test-provider');
Setting.setValue('ai.enabled', true);
});
test('should allow external tools when enabled in settings', async () => {
const note = await Note.save({ title: 'test' });
const toolOptions = {
note_id: note.id,
add: ['test'],
};
const toolCallMessage = `/tool manage_tags ${JSON.stringify(toolOptions)}`;
const runChat = () => (
runChatForNote(
note,
// Avoid the default history
[{ role: ChatRole.User, content: 'testing' }],
toolCallMessage,
)
);
// Should not allow managing tags when disabled in settings
Setting.setValue('ai.tool.manage_tags.enabled', false);
await runChat();
expect(await Tag.tagsByNoteId(note.id)).toHaveLength(0);
// Should allow managing tags when enabled
Setting.setValue('ai.tool.manage_tags.enabled', true);
await runChat();
expect(await Tag.tagsByNoteId(note.id)).toHaveLength(1);
});
test('running a disabled tool should provide information how to enable it', async () => {
const note = await Note.save({ title: 'test' });
const toolCallMessage = `/tool manage_tags ${JSON.stringify({ someArg: 'test' })}`;
const runChat = () => (
runChatForNote(
note,
// Avoid the default history
[{ role: ChatRole.User, content: 'testing' }],
toolCallMessage,
)
);
// Should not allow managing tags when disabled in settings
Setting.setValue('ai.tool.manage_tags.enabled', false);
const messages = await runChat();
const infoMessage = messages.find(message => message.role === ChatRole.Tool && message.toolName === 'manage_tags');
expect(infoMessage).toBeTruthy();
expect(infoMessage.content).toContain('Tool `manage_tags` is disabled in Joplin\'s settings');
});
});
+274 -133
View File
@@ -1,11 +1,12 @@
import AiService from './AiService';
import { ChatMessage, ChatResult, ChatRole, ChatToolCall, ChatToolMessage } from './types';
import { ChatMessage, ChatResult, ChatRole, ChatToolCall, ChatToolMessage, ToolSpec } from './types';
import JoplinError from '../../JoplinError';
import Logger from '@joplin/utils/Logger';
import { _ } from '../../locale';
import { EditOp, isEditorToolCall } from './tools/buildEditorTools';
import { NoteContext, ToolError } from './tools/types';
import ToolIndex from './tools/ToolIndex';
import findFencedBlock from './utils/findFencedBlock';
import { applyAnchorEdits, supportedStructuredBlockTags } from './applyNoteEdits';
import { hasOwnProperty } from '@joplin/utils/object';
const Countable = require('../../countable/Countable');
import { _, _n } from '../../locale';
const logger = Logger.create('noteChat');
@@ -15,6 +16,25 @@ const noteBodyTokenBudget = 80000;
const charsPerToken = 4;
export type EditOp =
| { op: 'replaceSelection'; text: string }
| { op: 'insertBefore'; anchor: string; text: string }
| { op: 'insertAfter'; anchor: string; text: string }
| { op: 'appendToNote'; text: string }
| { op: 'replaceRange'; anchor: string; text: string }
| { op: 'replaceFencedBlock'; tag: string; text: string }
;
const knownOps = new Set<EditOp['op']>([
'replaceSelection', 'insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'replaceFencedBlock',
]);
export interface NoteContext {
title: string;
body: string;
selection: string | null;
}
export interface ChatReply {
reply: string;
edits: EditOp[];
@@ -36,14 +56,15 @@ const joplinMarkdownNotes = [
'- HTML is allowed for features without a Markdown equivalent (e.g. `<s>strikethrough</s>`).',
].join('\n');
// The system prompt should be relatively constant across note changes to take advantage of prompt caching.
// See https://developers.openai.com/api/docs/guides/prompt-caching
const hasStructuredBlock = (note: NoteContext) => {
return supportedStructuredBlockTags.some(tag => !!findFencedBlock(note.body, tag, 0));
};
const systemPrompt = (note: NoteContext) => {
const lines: string[] = [
'You are an assistant helping the user work on a note in Joplin, a note-taking application.',
'',
`Note title: ${note.title || '(untitled)'}`,
`Note ID: ${note.noteId}`,
'',
joplinMarkdownNotes,
'',
@@ -55,6 +76,12 @@ const systemPrompt = (note: NoteContext) => {
lines.push(note.selection);
lines.push('--- END SELECTION ---');
lines.push();
} else {
lines.push('Note body:');
lines.push('--- BEGIN NOTE ---');
lines.push(note.body);
lines.push('--- END NOTE ---');
lines.push();
}
if (note.selection) {
@@ -62,8 +89,7 @@ const systemPrompt = (note: NoteContext) => {
} else {
lines.push(
'Tool guidance:',
'- You have access to various tools that allow updating the note. For example, if you need to add the text "test" to the end of the note, do this using the "editor.appendToNote" tool.',
'- For tasks modifying the current note, prefer "editor." tools to global tools.',
'- You have access to various tools that allow updating the note. For example, if you need to add the text "test" to the end of the note, do this using the "appendToNote" tool.',
'- Some tools call for anchors. Anchors must be exact substrings of the current note body. Keep them short but unique.',
);
}
@@ -73,43 +99,104 @@ const systemPrompt = (note: NoteContext) => {
return lines.join('\n');
};
const createHistory = (history: ChatMessage[], newMessage: string, context: NoteContext): ChatMessage[] => {
history = removeSystemPrompt(history);
const toolDefinitions = (note: NoteContext) => {
const result: ToolSpec[] = [];
const isNewChat = history.length === 0 && !context.selection;
history = [
{ role: ChatRole.System, content: systemPrompt(context) },
...history,
{ role: ChatRole.User, content: newMessage },
];
// The assistant almost always needs to fetch the current content of the note. Initializing the
// chat transcript with a `readNote` tool call saves one round-trip:
const addReadNote = isNewChat;
if (addReadNote) {
const callId = `call_read${history.length}`;
history.push(
{
role: ChatRole.Assistant,
content: '',
hide: true,
toolCalls: [
{ callId, arguments: { }, toolName: 'editor.readNoteBody', parseError: null },
],
if (note.selection) {
result.push({
name: 'replaceSelection',
description: 'Replaces the text currently selected by the user.',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
additionalProperties: false,
},
});
} else {
result.push(
{
role: ChatRole.Tool,
content: context.body,
toolCallId: callId,
toolName: 'editor.readNoteBody',
userDescription: '',
isEdit: false,
isError: false,
name: 'appendToNote',
description: 'Adds text to the end of the note. Text is always added to the end of the note. This tool does not require an anchor.',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
additionalProperties: false,
},
},
);
const anchoredSchema = (anchorDescription: string, textDescription: string) => ({
type: 'object',
properties: {
anchor: { type: 'string', description: anchorDescription },
text: { type: 'string', description: textDescription },
},
required: ['anchor', 'text'],
additionalProperties: false,
});
result.push(
{
name: 'insertBefore',
description: 'Insert text immediately before the first occurrence of `anchor`.',
inputSchema: anchoredSchema(
'Text to find',
'What to insert just **before** the found text',
),
},
{
name: 'insertAfter',
description: 'Insert text immediately after the first occurrence of `anchor`.',
inputSchema: anchoredSchema(
'Text to find',
'What to insert just **after** the found text',
),
},
{
name: 'replaceRange',
description: 'Replace the first occurrence of `anchor` with `text`.',
inputSchema: anchoredSchema(
'The text to replace',
'What to replace it with',
),
},
);
const hasFencedBlock = hasStructuredBlock(note);
if (hasFencedBlock) {
result.push({
name: 'replaceFencedBlock',
description: [
'Replaces the inner content of the first ```<tag>``` fenced block.',
`"text" is the new content inside the fence (no fence markers). Supported tags: ${supportedStructuredBlockTags.join(', ')}.`,
'Use appendToNote to create a new fenced block.',
].join(' '),
inputSchema: {
type: 'object',
properties: {
tag: {
type: 'string',
enum: supportedStructuredBlockTags,
},
text: {
type: 'string',
description: 'The new content for the block content. Should not include the fenced block delimiters.',
},
},
required: ['tag', 'text'],
additionalProperties: false,
},
});
}
}
return history;
return result;
};
const estimateTokens = (text: string) => Math.ceil(text.length / charsPerToken);
@@ -131,8 +218,14 @@ export const runNoteChat = async (
onHistoryChanged: OnHistoryChanged,
signal: AbortSignal,
) => {
const initialContext = await context();
history = createHistory(history, userMessage, initialContext);
history = [
// Ensure that the system prompt is up-to-date with the context.
// TODO: This will invalidate the prompt cache.
// See https://developers.openai.com/api/docs/guides/prompt-caching
{ role: ChatRole.System, content: systemPrompt(await context()) },
...removeSystemPrompt(history),
{ role: ChatRole.User, content: userMessage },
];
onHistoryChanged([...history]);
const hasUndeliveredToolResults = () => history[history.length - 1]?.role === ChatRole.Tool;
@@ -174,15 +267,12 @@ interface StepNoteChatOptions {
const stepNoteChat = async ({
context, messages, commands, onHistoryChanged, signal, allowTools,
}: StepNoteChatOptions) => {
assertWithinTokenBudget(messages, noteBodyTokenBudget);
const note = await context();
const toolIndex = new ToolIndex({ note, commands });
const chatResult = await AiService.instance().chat(messages, {
tools: allowTools ? toolIndex.getTools() : [],
signal,
});
assertWithinTokenBudget(messages, noteBodyTokenBudget);
const chatResult = await AiService.instance().chat(
messages, { tools: allowTools ? toolDefinitions(note) : [], signal },
);
// Chat results can contain sensitive information -- only log in dev mode
logger.debug('Received chat result', chatResult);
@@ -195,9 +285,7 @@ const stepNoteChat = async ({
let failedToolCount = 0;
if (allowTools) {
const toolResults = await runTools(
chatResult, note, context, commands, signal,
);
const toolResults = await runTools(chatResult, note, context, commands, signal);
messages.push(...toolResults);
onHistoryChanged([...messages]);
@@ -250,116 +338,169 @@ const assertNotCancelled = (signal: AbortSignal) => {
}
};
const runTools = async (
chat: ChatResult, initialContext: NoteContext, getContext: OnContext, commands: ChatCommands, signal: AbortSignal,
) => {
const runTools = async (chat: ChatResult, initialContext: NoteContext, context: OnContext, commands: ChatCommands, signal: AbortSignal) => {
assertNotCancelled(signal);
const currentContext = await getContext();
let chatResponses: ChatToolMessage[] = [];
const isEdit = (toolName: string) => isEditorToolCall(toolName) && toolName !== 'editor.readNoteBody';
const respondSuccess = (action: ChatToolCall, message: string) => {
chatResponses.push({
role: ChatRole.Tool,
toolName: action.toolName,
toolCallId: action.callId,
content: message,
userDescription: message,
isError: false,
});
};
const respondFailure = (action: ChatToolCall, reason: string, userDescription?: string) => {
const respondFailure = (action: ChatToolCall, reason: string) => {
chatResponses.push({
role: ChatRole.Tool,
toolName: action.toolName,
toolCallId: action.callId,
content: `failed: ${reason}`,
userDescription: userDescription ?? reason,
userDescription: reason,
isError: true,
isEdit: isEdit(action.toolName),
});
};
const runTool = async (toolCall: ChatToolCall, body: string) => {
const index = new ToolIndex({
note: { ...currentContext, body },
commands: {
...commands,
updateNoteBody: newBody => {
body = newBody;
return Promise.resolve();
},
},
});
const toolId = toolCall.toolName;
const tool = index.findTool(toolId);
if (tool) {
try {
const output = await tool.handler(toolCall.arguments, { selectedFolderId: currentContext.folderId });
chatResponses.push({
role: ChatRole.Tool,
toolName: tool.id,
toolCallId: toolCall.callId,
content: typeof output === 'string' ? output : JSON.stringify(output),
userDescription: tool.userDescription(toolCall.arguments, output),
isError: false,
isEdit: isEdit(tool.id),
});
} catch (error) {
logger.error('Tool call', toolCall.toolName, 'failed', error);
if (error instanceof ToolError) {
respondFailure(toolCall, error.message);
} else {
respondFailure(toolCall, 'failed');
// Only replaceSelection operations allowed when there's a selection
const currentContext = await context();
if (currentContext.selection) {
const expectedName = 'replaceSelection';
const action = chat.toolCalls.find(toolCall => toolCall.toolName === expectedName);
if (action) {
const args = action.arguments;
if (action.parseError) {
respondFailure(action, action.parseError);
} else if (!hasOwnProperty(args, 'text') || typeof args.text !== 'string') {
respondFailure(action, 'missing or invalid `text` property');
} else {
try {
await commands.replaceSelection(args.text, currentContext.selection);
respondSuccess(action, describeEditOperation(toolCallToEditOperation(action)));
} catch (error) {
logger.error('Failed to replace selection', error);
respondFailure(action, 'failed to replace selection');
commands.displayError(error.message ?? String(error));
}
}
} else {
respondFailure(
toolCall,
index.describeToolNotFoundFailure(toolId),
_('Tool not found: %s', toolId),
);
}
return body;
};
const initialBody = initialContext.body;
let body = currentContext.body;
const hadExternalBodyChanges = initialBody !== body;
for (const toolCall of chat.toolCalls) {
if (toolCall.parseError) {
respondFailure(toolCall, toolCall.parseError);
} else {
body = await runTool(toolCall, body);
// Only one tool call is allowed in this context
for (const toolCall of chat.toolCalls) {
if (action === toolCall) continue;
if (toolCall.toolName === expectedName) {
respondFailure(toolCall, `Only a single ${expectedName} tool call is valid in this context`);
} else {
respondFailure(toolCall, `Only the ${expectedName} tool is valid in this context.`);
}
}
}
} else {
const initialBody = initialContext.body;
let body = currentContext.body;
const appliedEdits = chatResponses.filter(r => r.isEdit && !r.isError);
if (appliedEdits.length > 0 && body !== initialBody) {
try {
// Avoid overwriting user-created changes
if (hadExternalBodyChanges) {
throw new Error(_('The note changed while the request was running; edits were not applied. Try again.'));
if (initialBody !== body && chat.toolCalls.length > 0) {
for (const toolCall of chat.toolCalls) {
respondFailure(toolCall, 'body changed externally, before edit could be applied');
}
commands.displayError(_('The note changed while the request was running; edits were not applied. Try again.'));
return chatResponses;
}
for (const toolCall of chat.toolCalls) {
if (toolCall.toolName === 'replaceSelection') {
respondFailure(toolCall, 'replaceSelection is invalid in this context');
continue;
}
if (!isValidEditOp(toolCall.toolName)) {
respondFailure(toolCall, 'tool not found');
continue;
}
if (toolCall.parseError) {
respondFailure(toolCall, toolCall.parseError);
continue;
}
await commands.updateNoteBody(body, initialBody);
} catch (error) {
logger.error('Failed to update body', error);
const editOperation = toolCallToEditOperation(toolCall);
const { newBody, appliedEdits } = applyAnchorEdits(body, [editOperation], 0);
body = newBody;
// All of the edit operations depend on updating the body, so they all
// need to be marked as failures:
const successfulEditResponses = new Set(appliedEdits);
chatResponses = chatResponses.map(response => {
if (!successfulEditResponses.has(response)) return response;
return {
...response,
content: 'failed to update body',
isError: true,
};
});
if (appliedEdits.length !== 1) {
throw new Error(`Invalid state: Wrong number of applied edits, ${appliedEdits.length}`);
}
commands.displayError(error.message ?? String(error));
const edit = appliedEdits[0];
if (edit.status === 'applied') {
respondSuccess(toolCall, describeEditOperation(editOperation));
} else {
respondFailure(toolCall, edit.status);
}
}
if (body !== initialBody) {
try {
await commands.updateNoteBody(body, initialBody);
} catch (error) {
logger.error('Failed to update body', error);
// Replace any 'success' messages with failures, since all tools
// currently depend on updating the body.
chatResponses = [];
for (const toolCall of chat.toolCalls) {
respondFailure(toolCall, 'failed to update body');
}
commands.displayError(error.message ?? String(error));
}
}
}
return chatResponses;
};
const isValidEditOp = (operation: string): operation is EditOp['op'] => {
return knownOps.has(operation as EditOp['op']);
};
const toolCallToEditOperation = (toolCall: ChatToolCall): EditOp => {
const op = toolCall.toolName;
if (!isValidEditOp(op)) {
throw new Error(`Invalid edit operation: ${op}`);
}
const args = toolCall.arguments;
return {
op,
...('text' in args && typeof args.text === 'string' ? { text: args.text } : {}),
...('anchor' in args && typeof args.anchor === 'string' ? { anchor: args.anchor } : {}),
...('tag' in args && typeof args.tag === 'string' ? { tag: args.tag } : {}),
} as EditOp;
};
const countWords = (text: string): number => Countable.countOnce(text, { stripTags: true }).words;
const describeEditOperation = (editOp: EditOp) => {
const describeWordsAdded = (count: number) => _n('Added %d word', 'Added %d words', count, count);
const describeWordsRemoved = (count: number) => _n('Removed %d word', 'Removed %d words', count, count);
let actionDescription;
if (editOp.op === 'replaceRange') {
actionDescription = [
describeWordsRemoved(countWords(editOp.anchor)),
describeWordsAdded(countWords(editOp.text)),
].join('\n');
} else if (editOp.op === 'replaceFencedBlock') {
actionDescription = _('Updated %s block', editOp.tag);
} else if (editOp.op === 'replaceSelection') {
actionDescription = _('Replaced selection');
} else {
actionDescription = describeWordsAdded(countWords(editOp.text));
}
return actionDescription;
};
// Exported for tests.
export const _internal = { systemPrompt, assertWithinTokenBudget, estimateTokens, runTools, noteBodyTokenBudget };
export const _internal = { systemPrompt, toolDefinitions, assertWithinTokenBudget, estimateTokens, runTools, noteBodyTokenBudget };
@@ -20,7 +20,6 @@ describe('ai/providers/Anthropic', () => {
toolCallId: 'call-1',
toolName: 'replaceRange',
isError: false,
isEdit: true,
},
{
role: ChatRole.Tool,
@@ -29,7 +28,6 @@ describe('ai/providers/Anthropic', () => {
toolCallId: 'call-2',
toolName: 'replaceRange',
isError: true,
isEdit: true,
},
{
role: ChatRole.User,
@@ -177,7 +177,7 @@ export default class AnthropicProvider extends ChatProviderBase {
if (options?.tools) {
body.tools = options.tools.map(tool => {
return {
name: tool.id,
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
};
@@ -61,11 +61,9 @@ export const mapErrorByCode = (code: string | number | null, status: number, det
// For now, avoid more than one event every second or two.
const minimumTimeBetweenEvents = Second;
type ApiError = Error & { code?: number|string; retryAfterSeconds?: number };
type ApiError = Error & { code?: number; retryAfterSeconds?: number };
const isRateLimitError = (error: ApiError) => (
(error.code === 429 || error.code === 'aiRateLimitExceeded') && typeof error.retryAfterSeconds === 'number'
);
const isRateLimitError = (error: ApiError) => error.code === 429 && typeof error.retryAfterSeconds === 'number';
const canAutoRetryError = (error: ApiError) => {
return isRateLimitError(error) && error.retryAfterSeconds < 10;
@@ -2,9 +2,8 @@ import shim from '../../../shim';
import JoplinError from '../../../JoplinError';
import Logger from '@joplin/utils/Logger';
import { rtrimSlashes } from '@joplin/utils/path';
import { ChatMessage, ChatOptions, ChatResult, ChatToolCall, ProviderClassification } from '../types';
import { ChatMessage, ChatOptions, ChatResult, ChatToolCall, ProviderClassification, ToolSpec } from '../types';
import ChatProviderBase from './ChatProviderBase';
import { ToolSpec } from '../tools/types';
const logger = Logger.create('OpenAiCompatibleProvider');
@@ -49,7 +48,7 @@ const convertTool = (tool: ToolSpec) => {
return {
type: 'function',
function: {
name: tool.id,
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
strict: true,
@@ -1,5 +1,4 @@
import { ToolSpec } from '../tools/types';
import { ChatMessage, ChatOptions, ChatResult, ChatRole, ChatToolCall, ProviderClassification } from '../types';
import { ChatMessage, ChatOptions, ChatResult, ChatRole, ChatToolCall, ProviderClassification, ToolSpec } from '../types';
import ChatProviderBase from './ChatProviderBase';
const parseUserCommand = (message: ChatMessage, availableTools: ToolSpec[]) => {
@@ -7,19 +6,14 @@ const parseUserCommand = (message: ChatMessage, availableTools: ToolSpec[]) => {
const reply = [];
let repeat = 0;
for (const line of message.content.split('\n')) {
const command = line.match(/^\/(tool|describe-tool|reply-with|repeat) (.*)$/);
const command = line.match(/^\/(tool|reply-with|repeat) (.*)$/);
if (!command) continue;
const isToolCall = command[1] === 'tool';
const isDescribeToolRequest = command[1] === 'describe-tool';
if (isToolCall || isDescribeToolRequest) {
if (command[1] === 'tool') {
const args = command[2].split(' ');
const toolName = args[0];
const tool = availableTools.find(t => t.id === toolName);
if (!tool) {
if (!availableTools.some(tool => tool.name === toolName)) {
reply.push(`tool not found: ${toolName}`);
} else if (isDescribeToolRequest) {
reply.push(`${toolName}: ${tool.description}`);
} else {
toolCalls.push({
callId: `tool-call-${toolCalls.length}`,
@@ -1,62 +0,0 @@
import { substrWithEllipsis } from '../../../string-utils';
import { EditorToolContext, ToolDefinition } from './types';
import { toolSettingKey, toolSettingName, toolsSectionName } from './utils/settings';
import globalTools from './global/index';
import buildEditorTools from './buildEditorTools';
import Setting from '../../../models/Setting';
export default class ToolIndex {
private tools_: ToolDefinition[];
public constructor(private editorContext_: EditorToolContext|null) {
this.tools_ = [
...globalTools,
...(this.editorContext_ ? buildEditorTools(this.editorContext_) : []),
];
}
private isEnabled(toolId: string) {
const tool = this.tools_.find(tool => tool.id === toolId);
if (!tool) return false;
// For tools with no corresponding setting
if (tool.enabled !== undefined) return tool.enabled;
const settingName = toolSettingKey(toolId);
return !!Setting.valueNoThrow(settingName, false);
}
public getTools() {
return this.tools_.map(tool => {
const enabled = this.isEnabled(tool.id);
return {
...tool,
description: enabled ? tool.description : [
'(Disabled tool)',
substrWithEllipsis(tool.description, 0, 64),
`**Disabled**: The setting "${toolSettingName(tool.id)}" must be enabled to use this tool.`,
].join('\n'),
};
});
}
public findTool(id: string) {
if (!this.isEnabled(id)) return null;
return this.getTools().find(tool => tool.id === id);
}
public describeToolNotFoundFailure(toolId: string) {
const settingName = toolSettingName(toolId);
// Return "disabled" vs "unknown" differently so the LLM gets actionable feedback.
if (!settingName) return `Unknown tool: '${toolId}'`;
return [
`# Tool \`${toolId}\` is disabled in Joplin's settings`,
'',
'If you need this tool, ask the user to enable it for you. The user can enable this tool by:',
'1. opening Joplin\'s settings screen,',
`2. opening the "${toolsSectionName()}" tab, and`,
`3. enabling the "${settingName}" setting.`,
].join('\n');
}
}
@@ -1,236 +0,0 @@
import { hasOwnProperty } from '@joplin/utils/object';
import { _, _n } from '../../../locale';
import { NoteContext, ToolDefinition, ToolError, ToolInput } from './types';
import { EditorToolContext } from './types';
const Countable = require('../../../countable/Countable');
import buildTool from './utils/buildTool';
import Logger from '@joplin/utils/Logger';
import { applyAnchorEdits, supportedStructuredBlockTags } from './utils/applyNoteEdits';
import findFencedBlock from '../utils/findFencedBlock';
const logger = Logger.create('buildEditorTools');
export type EditOp =
| { op: 'replaceSelection'; text: string }
| { op: 'insertBefore'; anchor: string; text: string }
| { op: 'insertAfter'; anchor: string; text: string }
| { op: 'appendToNote'; text: string }
| { op: 'replaceRange'; anchor: string; text: string }
| { op: 'replaceFencedBlock'; tag: string; text: string }
;
const knownOps = new Set<EditOp['op']>([
'replaceSelection', 'insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'replaceFencedBlock',
]);
// Tools that make changes to the current note body
const isValidEditOp = (operation: string): operation is EditOp['op'] => {
return knownOps.has(operation as EditOp['op']);
};
// Tools that interact with the editor
export const isEditorToolCall = (toolId: string) => {
if (!toolId.startsWith('editor.')) return false;
toolId = toolId.replace(/^editor\./, '');
return isValidEditOp(toolId) || toolId === 'readNoteBody';
};
const toolCallToEditOperation = (operationId: EditOp['op'], args: ToolInput): EditOp => {
return {
op: operationId,
...('text' in args && typeof args.text === 'string' ? { text: args.text } : {}),
...('anchor' in args && typeof args.anchor === 'string' ? { anchor: args.anchor } : {}),
...('tag' in args && typeof args.tag === 'string' ? { tag: args.tag } : {}),
} as EditOp;
};
const countWords = (text: string): number => Countable.countOnce(text, { stripTags: true }).words;
const describeEditOperation = (editOp: EditOp) => {
const describeWordsAdded = (count: number) => _n('Added %d word', 'Added %d words', count, count);
const describeWordsRemoved = (count: number) => _n('Removed %d word', 'Removed %d words', count, count);
let actionDescription;
if (editOp.op === 'replaceRange') {
actionDescription = [
describeWordsRemoved(countWords(editOp.anchor)),
describeWordsAdded(countWords(editOp.text)),
].join('\n');
} else if (editOp.op === 'replaceFencedBlock') {
actionDescription = _('Updated %s block', editOp.tag);
} else if (editOp.op === 'replaceSelection') {
actionDescription = _('Replaced selection');
} else {
actionDescription = describeWordsAdded(countWords(editOp.text));
}
return actionDescription;
};
const hasStructuredBlock = (note: NoteContext) => {
return supportedStructuredBlockTags.some(tag => !!findFencedBlock(note.body, tag, 0));
};
const buildEditorTools = ({ note, commands }: EditorToolContext) => {
const result: ToolDefinition[] = [];
const addSelectionOperations = () => {
result.push(buildTool({
id: 'editor.replaceSelection',
userDescription: (_input: ToolInput, output: string) => output,
description: 'Replaces the text currently selected by the user.',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
additionalProperties: false,
},
handler: async (args: ToolInput) => {
if (!hasOwnProperty(args, 'text') || typeof args.text !== 'string') {
throw new ToolError('missing or invalid `text` property');
} else {
try {
await commands.replaceSelection(args.text, note.selection);
} catch (error) {
logger.error(error.message ?? String(error));
throw new ToolError('failed to replace selection');
}
return describeEditOperation({ op: 'replaceSelection', text: args.text });
}
},
}));
};
const addEditOperations = () => {
const buildEditTool = (id: EditOp['op']) => ({
id: `editor.${id}`,
userDescription: (args: ToolInput) => (
describeEditOperation(toolCallToEditOperation(id, args))
),
handler: async (args: ToolInput) => {
const editOperation = toolCallToEditOperation(id, args);
const { newBody, appliedEdits } = applyAnchorEdits(note.body, [editOperation], 0);
await commands.updateNoteBody(newBody, note.body);
if (appliedEdits.length !== 1) {
throw new Error(`Invalid state: Wrong number of applied edits, ${appliedEdits.length}`);
}
const edit = appliedEdits[0];
if (edit.status === 'applied') {
return describeEditOperation(editOperation);
} else {
throw new ToolError(edit.status);
}
},
});
result.push(
{
...buildEditTool('appendToNote'),
description: 'Adds text to the end of the current note: Text is always added to the end of the note. This tool does not require an anchor.',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
additionalProperties: false,
},
},
);
const anchoredSchema = (anchorDescription: string, textDescription: string) => ({
type: 'object',
properties: {
anchor: { type: 'string', description: anchorDescription },
text: { type: 'string', description: textDescription },
},
required: ['anchor', 'text'],
additionalProperties: false,
});
result.push(
{
...buildEditTool('insertBefore'),
description: 'Add text before an `anchor` in the current note: Insert text immediately before the first occurrence of `anchor`.',
inputSchema: anchoredSchema(
'Text to find',
'What to insert just **before** the found text',
),
},
{
...buildEditTool('insertAfter'),
description: 'Add text after an `anchor` in the current note: Inserts text immediately after the first occurrence of `anchor` in the current note.',
inputSchema: anchoredSchema(
'Text to find',
'What to insert just **after** the found text',
),
},
{
...buildEditTool('replaceRange'),
description: 'Remove or replace text in the current note: Replaces the first occurrence of `anchor` with `text`.',
inputSchema: anchoredSchema(
'The text to replace',
'What to replace it with',
),
},
);
const hasFencedBlock = hasStructuredBlock(note);
if (hasFencedBlock) {
result.push({
...buildEditTool('replaceFencedBlock'),
description: [
'Replaces the inner content of the first ```<tag>``` fenced block in the current note.',
`"text" is the new content inside the fence (no fence markers). Supported tags: ${supportedStructuredBlockTags.join(', ')}.`,
'Use appendToNote to create a new fenced block.',
].join(' '),
inputSchema: {
type: 'object',
properties: {
tag: {
type: 'string',
enum: supportedStructuredBlockTags,
},
text: {
type: 'string',
description: 'The new content for the block content. Should not include the fenced block delimiters.',
},
},
required: ['tag', 'text'],
additionalProperties: false,
},
});
}
};
const addReadOperation = () => {
result.push(
{
id: 'editor.readNoteBody',
// Always enabled in the editor context:
enabled: true,
userDescription: () => _('Read note'),
description: 'Get the current, up-to-date content of the note. This returns the full content of the note that\'s currently open in Joplin\'s editor.',
inputSchema: {
type: 'object',
required: [],
additionalProperties: false,
},
handler: () => {
return Promise.resolve(note.body);
},
},
);
};
if (note.selection) {
addSelectionOperations();
} else {
addReadOperation();
addEditOperations();
}
return result;
};
export default buildEditorTools;
@@ -1,23 +0,0 @@
import searchNotes from './searchNotes';
import semanticSearchNotes from './semanticSearchNotes';
import readNote from './readNote';
import listNotebooks from './listNotebooks';
import listTags from './listTags';
import createNote from './createNote';
import updateNote from './updateNote';
import deleteNote from './deleteNote';
import manageTags from './manageTags';
import createNotebook from './createNotebook';
export default [
searchNotes,
semanticSearchNotes,
readNote,
listNotebooks,
listTags,
createNote,
updateNote,
deleteNote,
manageTags,
createNotebook,
];
-46
View File
@@ -1,46 +0,0 @@
import { JsonSchema } from '../types';
export interface EditorCommands {
replaceSelection: (text: string, originalText: string)=> Promise<void>;
updateNoteBody: (body: string, originalBody: string)=> Promise<void>;
}
export interface NoteContext {
title: string;
noteId: string;
folderId: string;
body: string;
selection: string | null;
}
export interface EditorToolContext {
note: NoteContext;
commands: EditorCommands;
}
export type ToolInput = Record<string, unknown>;
// Throw this from a tool handler for failure modes the LLM should see and
// recover from (note not found, ambiguous match, missing parameter, etc.).
// Plain Errors are treated as internal bugs.
export class ToolError extends Error {}
export type ToolOutput = string|Record<string, unknown>;
export interface ToolSpec {
id: string;
description: string;
// Information provided by the model to the tool
inputSchema: JsonSchema;
}
export interface ToolContext {
selectedFolderId?: string;
}
export type ToolDefinition<Output extends ToolOutput|unknown = unknown> = ToolSpec & {
enabled?: boolean;
handler: (input: ToolInput, context: ToolContext)=> Promise<Output>;
// A human-readable description of an action completed by the tool
userDescription: (input: ToolInput, output: Output)=> string;
};
@@ -1,134 +0,0 @@
import { applyAnchorEdits } from './applyNoteEdits';
describe('applyNoteEdits', () => {
it('appends with paragraph break (covered in detail below)', () => {
const { newBody } = applyAnchorEdits('hello', [
{ op: 'appendToNote', text: 'world' },
], 0);
expect(newBody).toBe('hello\n\nworld');
});
it('inserts before/after anchor', () => {
const before = applyAnchorEdits('one two three', [
{ op: 'insertBefore', anchor: 'two', text: 'X ' },
], 0);
expect(before.newBody).toBe('one X two three');
const after = applyAnchorEdits('one two three', [
{ op: 'insertAfter', anchor: 'two', text: ' X' },
], 0);
expect(after.newBody).toBe('one two X three');
});
it('replaces range and reports missing anchor', () => {
const replaced = applyAnchorEdits('alpha beta gamma', [
{ op: 'replaceRange', anchor: 'beta', text: 'BETA' },
], 0);
expect(replaced.newBody).toBe('alpha BETA gamma');
const missing = applyAnchorEdits('alpha beta gamma', [
{ op: 'replaceRange', anchor: 'delta', text: 'X' },
], 0);
expect(missing.newBody).toBe('alpha beta gamma');
expect(missing.appliedEdits[0].status).toBe('anchor-not-found');
});
it('picks anchor closest to cursor on duplicates', () => {
// "foo" appears at index 0 and index 8. Cursor near second occurrence.
const body = 'foo bar foo baz';
const { newBody } = applyAnchorEdits(body, [
{ op: 'insertAfter', anchor: 'foo', text: '!' },
], 10);
expect(newBody).toBe('foo bar foo! baz');
});
it('appends with blank line for Markdown paragraph break', () => {
// Empty body — no separator needed.
expect(applyAnchorEdits('', [{ op: 'appendToNote', text: 'first' }], 0).newBody).toBe('first');
// No trailing newline — needs full \n\n.
expect(applyAnchorEdits('hello', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
// One trailing newline — add one more to make a blank line.
expect(applyAnchorEdits('hello\n', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
// Already has blank line — no extra separator.
expect(applyAnchorEdits('hello\n\n', [{ op: 'appendToNote', text: 'world' }], 0).newBody).toBe('hello\n\nworld');
});
it('rejects invalid edits without mutating the body', () => {
// Empty anchor, missing text, wrong shape, unknown op.
const body = 'hello world';
const cases = [
{ op: 'insertBefore', anchor: '', text: 'X' },
{ op: 'insertAfter', anchor: 'world' }, // missing text
{ op: 'appendToNote' }, // missing text
{ op: 'mysteryOp', text: 'X' },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = applyAnchorEdits(body, cases as any, 0);
expect(result.newBody).toBe(body);
expect(result.appliedEdits.every(e => e.status === 'invalid')).toBe(true);
});
it('replaces the inner content of a fenced block', () => {
const body = 'Intro paragraph.\n\n```jsoncanvas\n{"nodes":[],"edges":[]}\n```\n\nOutro.';
const { newBody, appliedEdits } = applyAnchorEdits(body, [
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"nodes":[{"id":"a"}],"edges":[]}' },
], 0);
expect(appliedEdits[0].status).toBe('applied');
expect(newBody).toContain('```jsoncanvas\n{"nodes":[{"id":"a"}],"edges":[]}\n```');
// Surrounding prose is preserved.
expect(newBody).toContain('Intro paragraph.');
expect(newBody).toContain('Outro.');
});
it('picks the fenced block closest to the cursor', () => {
const first = '```jsoncanvas\n{"v":1}\n```';
const second = '```jsoncanvas\n{"v":2}\n```';
const body = `${first}\n\nSome prose between.\n\n${second}`;
// Cursor near the second block — should edit that one, leaving the first untouched.
const cursorNearSecond = body.indexOf(second) + 5;
const { newBody } = applyAnchorEdits(body, [
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"v":99}' },
], cursorNearSecond);
expect(newBody).toContain('{"v":1}'); // first untouched
expect(newBody).toContain('{"v":99}'); // second replaced
expect(newBody).not.toContain('{"v":2}');
// Cursor at 0 — first block wins (fallback behaviour preserved).
const { newBody: firstWins } = applyAnchorEdits(body, [
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{"v":0}' },
], 0);
expect(firstWins).toContain('{"v":0}');
expect(firstWins).toContain('{"v":2}'); // second untouched
expect(firstWins).not.toContain('{"v":1}');
});
it('reports anchor-not-found when fenced block missing', () => {
const body = 'No whiteboard in this note.';
const { newBody, appliedEdits } = applyAnchorEdits(body, [
{ op: 'replaceFencedBlock', tag: 'jsoncanvas', text: '{}' },
], 0);
expect(newBody).toBe(body);
expect(appliedEdits[0].status).toBe('anchor-not-found');
});
it('rejects replaceFencedBlock with unsupported tag', () => {
// Plain code fences (```js, ```python, ...) are not structured-document
// formats — the model should use anchor-based ops instead.
const body = '```js\nconsole.log("x");\n```';
const { newBody, appliedEdits } = applyAnchorEdits(body, [
{ op: 'replaceFencedBlock', tag: 'js', text: 'console.log("y");' },
], 0);
expect(newBody).toBe(body);
expect(appliedEdits[0].status).toBe('invalid');
});
it('refuses replaceRange anchor covering most of the body', () => {
const body = 'short text';
// Anchor is >50% of body — would be destructive; refuse.
const { newBody, appliedEdits } = applyAnchorEdits(body, [
{ op: 'replaceRange', anchor: 'short text', text: '' },
], 0);
expect(newBody).toBe(body);
expect(appliedEdits[0].status).toBe('invalid');
});
});
@@ -1,7 +0,0 @@
import { ToolDefinition, ToolOutput } from '../types';
const buildTool = <OutputType extends ToolOutput> (spec: ToolDefinition<OutputType>) => {
return spec as ToolDefinition<unknown>;
};
export default buildTool;
@@ -1,17 +0,0 @@
import Setting from '../../../../models/Setting';
import { ToolDefinition } from '../types';
import { isEditorToolCall } from '../buildEditorTools';
export const toolSettingKey = (toolId: string) => (
isEditorToolCall(toolId) ? 'ai.tool.edit_current.enabled' : `ai.tool.${toolId}.enabled`
);
export const isToolEnabled = (tool: ToolDefinition) => !!Setting.value(toolSettingKey(tool.id));
export const toolSettingName = (toolId: string) => {
const key = toolSettingKey(toolId);
return Setting.keyExists(key) ? Setting.settingMetadata(key).label?.() : null;
};
export const toolsSectionName = () => Setting.sectionNameToLabel('ai.tools');
+7 -4
View File
@@ -1,5 +1,3 @@
import { ToolSpec } from './tools/types';
export enum ChatRole {
System = 'system',
User = 'user',
@@ -13,7 +11,6 @@ interface ChatBaseMessage {
export interface ChatStandardMessage extends ChatBaseMessage {
role: ChatRole.System | ChatRole.User | ChatRole.Assistant;
hide?: boolean;
toolCalls?: ChatToolCall[];
}
@@ -24,7 +21,6 @@ export interface ChatToolMessage extends ChatBaseMessage {
isError: boolean;
// A very brief description of the result that can be shown to the user
userDescription: string;
isEdit: boolean;
}
export type ChatMessage = ChatStandardMessage | ChatToolMessage;
@@ -46,6 +42,13 @@ export interface ResponseFormat {
};
}
export interface ToolSpec {
name: string;
description: string;
// Information provided by the model to the tool
inputSchema: JsonSchema;
}
export interface ChatOptions {
temperature?: number;
tools?: ToolSpec[];
@@ -4,8 +4,6 @@ import { FolderEntity, NoteEntity } from '../database/types';
import { getTrashFolderId } from '../trash';
import stateToWhenClauseContext from './stateToWhenClauseContext';
jest.mock('../noteLock/isNoteLockEnabled', () => () => true);
interface StateOptions {
folders: FolderEntity[];
notes: NoteEntity[];
@@ -13,8 +11,6 @@ interface StateOptions {
selectedFolderIds: string[];
selectedNoteIds: string[];
notesParentType: string;
noteLockSessionUnlocked: boolean;
activeNoteIsUndecryptable: boolean;
}
const buildState = (options: Partial<StateOptions>) => {
return {
@@ -25,48 +21,6 @@ const buildState = (options: Partial<StateOptions>) => {
};
describe('stateToWhenClauseContext', () => {
it.each([
{
label: 'the note lock session is locked',
noteLockSessionUnlocked: false,
activeNoteIsUndecryptable: false,
isLocked: 1,
expected: true,
},
{
label: 'the active note cannot be decrypted',
noteLockSessionUnlocked: true,
activeNoteIsUndecryptable: true,
isLocked: 1,
expected: true,
},
{
label: 'the locked note has been decrypted',
noteLockSessionUnlocked: true,
activeNoteIsUndecryptable: false,
isLocked: 1,
expected: false,
},
{
label: 'the active note is not locked',
noteLockSessionUnlocked: false,
activeNoteIsUndecryptable: true,
isLocked: 0,
expected: false,
},
])('should make the note read-only when $label', ({ noteLockSessionUnlocked, activeNoteIsUndecryptable, isLocked, expected }) => {
const applicationState = buildState({
selectedNoteIds: ['1'],
notes: [{ id: '1', is_locked: isLocked, deleted_time: 0 }],
noteLockSessionUnlocked,
activeNoteIsUndecryptable,
});
const resultingState = stateToWhenClauseContext(applicationState);
expect(resultingState.noteIsReadOnly).toBe(expected);
expect(resultingState.noteIsReadOnlyShare).toBe(false);
});
it('should be in trash if selected note has been deleted and selected folder is trash', async () => {
const applicationState = buildState({
selectedNoteIds: ['1'],
@@ -9,7 +9,6 @@ import ItemChange from '../../models/ItemChange';
import { getTrashFolderId } from '../trash';
import getActivePluginEditorView from '../plugins/utils/getActivePluginEditorView';
import { MarkupLanguage } from '@joplin/renderer';
import isNoteLockEnabled from '../noteLock/isNoteLockEnabled';
export interface WhenClauseContextOptions {
commandFolderId?: string;
@@ -42,12 +41,8 @@ export interface WhenClauseContext {
noNotesSelected: boolean;
noteIsDeleted: boolean;
noteIsHtml: boolean;
noteIsLocked: boolean;
noteLockContentUnavailable: boolean;
noteLockSessionUnlocked: boolean;
noteIsMarkdown: boolean;
noteIsReadOnly: boolean;
noteIsReadOnlyShare: boolean;
noteIsTodo: boolean;
notesAreBeingSaved: boolean;
noteTodoCompleted: boolean;
@@ -88,12 +83,6 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
: null;
const selectedFolderIsValid = !!selectedFolder && !selectedFolder.deleted_time;
const noteIsLocked = isNoteLockEnabled() && selectedNote ? !!selectedNote.is_locked : false;
// While the unlock or the cannot-decrypt overlay covers the editor, menu actions (attach file,
// insert date, etc.) could still modify the note underneath it, so treat the note as read-only.
const noteLockContentUnavailable = noteIsLocked && (!state.noteLockSessionUnlocked || windowState.activeNoteIsUndecryptable);
const noteIsReadOnlyShare = selectedNote ? itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, selectedNote as ItemSlice, settings['sync.userId'], state.shareService) : false;
return {
// Application state
notesAreBeingSaved: stateUtils.hasNotesBeingSaved(state),
@@ -126,11 +115,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
noteTodoCompleted: selectedNote ? !!selectedNote.todo_completed : false,
noteIsMarkdown: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false,
noteIsHtml: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false,
noteIsLocked,
noteLockContentUnavailable,
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
noteIsReadOnly: noteLockContentUnavailable || noteIsReadOnlyShare,
noteIsReadOnlyShare,
noteIsReadOnly: selectedNote ? itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, selectedNote as ItemSlice, settings['sync.userId'], state.shareService) : false,
noteIsDeleted: selectedNote ? !!selectedNote.deleted_time : false,
// Current context folder -- if multiple folders are selected, this only applies to one
@@ -1,9 +0,0 @@
import Setting from '../../models/Setting';
// Controls the whole conflict resolution feature. Use this wherever the UI
// or related code should stay hidden until the feature is ready.
const isConflictResolutionEnabled = () => {
return Setting.value('featureFlag.conflictResolution');
};
export default isConflictResolutionEnabled;
+17 -28
View File
@@ -8,16 +8,16 @@ import McpServer from './McpServer';
import { McpProtocolVersion } from './types';
const allToolSettings = [
'ai.tool.search_notes.enabled',
'ai.tool.semantic_search_notes.enabled',
'ai.tool.read_note.enabled',
'ai.tool.list_notebooks.enabled',
'ai.tool.list_tags.enabled',
'ai.tool.create_note.enabled',
'ai.tool.update_note.enabled',
'ai.tool.delete_note.enabled',
'ai.tool.manage_tags.enabled',
'ai.tool.create_notebook.enabled',
'mcp.tool.search_notes.enabled',
'mcp.tool.semantic_search_notes.enabled',
'mcp.tool.read_note.enabled',
'mcp.tool.list_notebooks.enabled',
'mcp.tool.list_tags.enabled',
'mcp.tool.create_note.enabled',
'mcp.tool.update_note.enabled',
'mcp.tool.delete_note.enabled',
'mcp.tool.manage_tags.enabled',
'mcp.tool.create_notebook.enabled',
];
const enableAllTools = () => {
@@ -45,28 +45,17 @@ describe('McpServer', () => {
expect(response.result.capabilities.tools).toBeDefined();
});
test('lists disabled tools as disabled', async () => {
Setting.setValue('ai.tool.search_notes.enabled', true);
Setting.setValue('ai.tool.read_note.enabled', true);
Setting.setValue('ai.tool.list_notebooks.enabled', true);
Setting.setValue('ai.tool.list_tags.enabled', true);
Setting.setValue('ai.tool.create_note.enabled', false);
Setting.setValue('ai.tool.update_note.enabled', false);
test('lists enabled tools only', async () => {
Setting.setValue('mcp.tool.create_note.enabled', false);
Setting.setValue('mcp.tool.update_note.enabled', false);
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/list',
});
type ToolSlice = { name: string; description: string };
const names = response.result.tools.map((t: ToolSlice) => t.name);
// Enabled
const names = response.result.tools.map((t: { name: string }) => t.name);
expect(names).toEqual(expect.arrayContaining(['search_notes', 'read_note', 'list_notebooks', 'list_tags']));
// Disabled
const createNote = response.result.tools.find((t: ToolSlice) => t.name === 'create_note');
const updateNote = response.result.tools.find((t: ToolSlice) => t.name === 'update_note');
expect(createNote).toBeTruthy();
expect(updateNote).toBeTruthy();
expect(createNote.description).toMatch(/^\(Disabled tool\)/);
expect(updateNote.description).toMatch(/^\(Disabled tool\)/);
expect(names).not.toContain('create_note');
expect(names).not.toContain('update_note');
});
test('returns MethodNotFound for unknown methods', async () => {
@@ -77,7 +66,7 @@ describe('McpServer', () => {
});
test('returns isError when calling a disabled tool', async () => {
Setting.setValue('ai.tool.search_notes.enabled', false);
Setting.setValue('mcp.tool.search_notes.enabled', false);
const response = await McpServer.instance().handleRequest({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'search_notes', arguments: { query: 'x' } },
+8 -8
View File
@@ -1,8 +1,7 @@
import Logger from '@joplin/utils/Logger';
import Setting from '../../models/Setting';
import ToolIndex from '../ai/tools/ToolIndex';
import { JsonRpcRequest, JsonRpcResponse, JsonRpcErrorCodes, McpProtocolVersion, ToolCallResult } from './types';
import { ToolError } from '../ai/tools/types';
import { allTools, enabledTools, findTool } from './registry';
import { JsonRpcRequest, JsonRpcResponse, JsonRpcErrorCodes, McpProtocolVersion, ToolCallResult, ToolError } from './types';
const logger = Logger.create('McpServer');
@@ -76,7 +75,7 @@ export default class McpServer {
private handleToolsList() {
return {
tools: new ToolIndex(null).getTools().map(t => ({
tools: enabledTools().map(t => ({
name: t.id,
description: t.description,
inputSchema: t.inputSchema,
@@ -89,14 +88,15 @@ export default class McpServer {
if (!params || typeof params.name !== 'string') {
throw new InvalidParamsError('Missing or invalid "name" parameter');
}
const index = new ToolIndex(null);
const tool = index.findTool(params.name);
const tool = findTool(params.name);
if (!tool) {
return toolErrorResult(index.describeToolNotFoundFailure(params.name));
// "Disabled" vs "unknown" surface differently so the LLM gets actionable feedback.
const exists = allTools().some(t => t.id === params.name);
return toolErrorResult(exists ? `Tool '${params.name}' is disabled in Joplin settings` : `Unknown tool '${params.name}'`);
}
const input = params.arguments ?? {};
try {
const payload = await tool.handler(input, {});
const payload = await tool.handler(input);
return {
content: [{ type: 'text', text: serialisePayload(payload) }],
};
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more