mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-26 03:06:27 +00:00
Compare commits
229
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f38c6f1f | ||
|
|
e05c11b17a | ||
|
|
949d10476f | ||
|
|
c397df5e4c | ||
|
|
2727aab2da | ||
|
|
e40b2f50f7 | ||
|
|
6da783abe5 | ||
|
|
845dc16402 | ||
|
|
fd3a2fb727 | ||
|
|
7d37bbe29c | ||
|
|
205c284fb8 | ||
|
|
a79a2b4de7 | ||
|
|
b7beb0bd06 | ||
|
|
5a4a222972 | ||
|
|
6d0f6c023a | ||
|
|
e51093d736 | ||
|
|
f488dad27a | ||
|
|
44751ef56b | ||
|
|
bf69aedf7c | ||
|
|
683240968b | ||
|
|
9bc4af486a | ||
|
|
7fba0fda68 | ||
|
|
3dc1f5fef6 | ||
|
|
8b97347f3a | ||
|
|
bfe78ca8a0 | ||
|
|
3899ca9ce5 | ||
|
|
098b1edaa5 | ||
|
|
4998ec221b | ||
|
|
3156f23fa2 | ||
|
|
347915c4ba | ||
|
|
8f98933f14 | ||
|
|
9b75d9e4d8 | ||
|
|
dff722d6d8 | ||
|
|
e6dc8d8222 | ||
|
|
97fb8d5ec2 | ||
|
|
1adc40ab30 | ||
|
|
8e9ffe3386 | ||
|
|
b6889d3e7d | ||
|
|
c174faf77d | ||
|
|
079bf7e48d | ||
|
|
0141c60d01 | ||
|
|
fc76a884f3 | ||
|
|
2a83642dbf | ||
|
|
b21a55e073 | ||
|
|
b8919220a7 | ||
|
|
f034ad244b | ||
|
|
73064ec468 | ||
|
|
94911a86ff | ||
|
|
92eed39ff7 | ||
|
|
55b1a88b88 | ||
|
|
50444adc23 | ||
|
|
0fa387b463 | ||
|
|
61c58955d3 | ||
|
|
14dc994e58 | ||
|
|
a8ea99fa59 | ||
|
|
6845e9d1ee | ||
|
|
99f0779db5 | ||
|
|
6a64879e05 | ||
|
|
67e6870ec9 | ||
|
|
7fb9ec93e4 | ||
|
|
27ae5c0d40 | ||
|
|
8353a82fc0 | ||
|
|
7766eefa11 | ||
|
|
1a0a50afc9 | ||
|
|
eade70040f | ||
|
|
d36432dd0a | ||
|
|
49756e09f1 | ||
|
|
7e4bdb64b1 | ||
|
|
dc4e0b464e | ||
|
|
b070bfed1f | ||
|
|
1aa705b97a | ||
|
|
05ae012ea3 | ||
|
|
36ef85f044 | ||
|
|
e1cf9245a5 | ||
|
|
011a15374c | ||
|
|
ae5855a85f | ||
|
|
8e7b1d763b | ||
|
|
fbd3a423a3 | ||
|
|
ec583f9c8a | ||
|
|
941418a271 | ||
|
|
d22ebdb0a1 | ||
|
|
c1efee1049 | ||
|
|
f1246530a4 | ||
|
|
85a61eee2d | ||
|
|
a349c8e081 | ||
|
|
66546b587a | ||
|
|
774aea1a38 | ||
|
|
7588c2b722 | ||
|
|
09e7e72273 | ||
|
|
9db5a8df36 | ||
|
|
ce36c14098 | ||
|
|
c1013461e0 | ||
|
|
cdee3c8901 | ||
|
|
eb800dc54b | ||
|
|
ce449a5a50 | ||
|
|
a36c689473 | ||
|
|
715c1d5514 | ||
|
|
d6dcd36c25 | ||
|
|
3b969d7f65 | ||
|
|
d73c9ca2dd | ||
|
|
7e66fbff34 | ||
|
|
561ab70c5e | ||
|
|
8cc7c95d67 | ||
|
|
9b71467e3b | ||
|
|
ca3482a8b3 | ||
|
|
36b7709072 | ||
|
|
34ded63b0c | ||
|
|
7fcb0eeadd | ||
|
|
7ec75ea70c | ||
|
|
55afc8622b | ||
|
|
629acbf89e | ||
|
|
a310163922 | ||
|
|
a43e772c85 | ||
|
|
bed73cd7c1 | ||
|
|
d4897e7f73 | ||
|
|
4afeb59450 | ||
|
|
21e73968e5 | ||
|
|
7ad86544cf | ||
|
|
d640f36ab1 | ||
|
|
b46a96e03c | ||
|
|
0f3d81a983 | ||
|
|
628e6bd6f6 | ||
|
|
925b83eafe | ||
|
|
82afd4a5f3 | ||
|
|
6255497969 | ||
|
|
942d0be0fa | ||
|
|
5d894d43c0 | ||
|
|
2f9c215bf8 | ||
|
|
0b07849f4d | ||
|
|
6233b510ff | ||
|
|
45b7feb683 | ||
|
|
2654b33620 | ||
|
|
eeb420fbdd | ||
|
|
4942bccd1e | ||
|
|
8b70b5f6e9 | ||
|
|
aed0d55398 | ||
|
|
39169894dd | ||
|
|
5d86c83e84 | ||
|
|
6cc0b97f64 | ||
|
|
dd145c39eb | ||
|
|
fbd88155dc | ||
|
|
20e55c9937 | ||
|
|
5d63d9c77b | ||
|
|
6e8fc6cd75 | ||
|
|
391cdf7f15 | ||
|
|
1c9ed87941 | ||
|
|
64343897ca | ||
|
|
b36592577d | ||
|
|
ca36776224 | ||
|
|
9fa7278c74 | ||
|
|
346a1f1e38 | ||
|
|
150ecb8976 | ||
|
|
a94f981ca9 | ||
|
|
c153ad8033 | ||
|
|
03efac1c17 | ||
|
|
747a1876fb | ||
|
|
8d1ba56e2f | ||
|
|
45c3cf6fcf | ||
|
|
8d8f76fe24 | ||
|
|
c5c23d6a52 | ||
|
|
7270b9f12b | ||
|
|
3745d33038 | ||
|
|
cb1f473bc6 | ||
|
|
03389b59a8 | ||
|
|
5ab92fe5b7 | ||
|
|
1704b5cf7d | ||
|
|
5395a8d02a | ||
|
|
69b5c6ab03 | ||
|
|
4fdfb5d90e | ||
|
|
bba1231448 | ||
|
|
140ff4552a | ||
|
|
f7998114ae | ||
|
|
15756bba1a | ||
|
|
1fba990153 | ||
|
|
1d7c5c0a44 | ||
|
|
d7c29c1d1a | ||
|
|
d167ae507c | ||
|
|
8bb852e639 | ||
|
|
b0ce2d06d3 | ||
|
|
a23e78825d | ||
|
|
cec9c97ef2 | ||
|
|
69a7fd19f1 | ||
|
|
6ca3d744ef | ||
|
|
0b8466addd | ||
|
|
b7031641f3 | ||
|
|
710f7983b3 | ||
|
|
e359f48ccc | ||
|
|
3c313a5b42 | ||
|
|
37feb9a6d0 | ||
|
|
f233b8780b | ||
|
|
ac40404d94 | ||
|
|
4d3dff767b | ||
|
|
ac53434542 | ||
|
|
58460dea63 | ||
|
|
e14393b118 | ||
|
|
980e1f4b06 | ||
|
|
b83c75d9d5 | ||
|
|
28c14d1825 | ||
|
|
0d5d4c4902 | ||
|
|
d14c25f43f | ||
|
|
7990ec79db | ||
|
|
94955ea573 | ||
|
|
b6eb739680 | ||
|
|
6caf347ee0 | ||
|
|
eeef83b081 | ||
|
|
15c78bca60 | ||
|
|
aae1a4ceaa | ||
|
|
b307898b47 | ||
|
|
c2c38fb7a0 | ||
|
|
c5913bc3d5 | ||
|
|
bf7689a6c4 | ||
|
|
19c0c808bd | ||
|
|
ee39f12a64 | ||
|
|
51c6d4f809 | ||
|
|
2fa45a5a05 | ||
|
|
7d90db0bf6 | ||
|
|
f606699717 | ||
|
|
995699230a | ||
|
|
e855fe6e4a | ||
|
|
322219593c | ||
|
|
4adbae2c88 | ||
|
|
87f82bb8fb | ||
|
|
8cd4502472 | ||
|
|
1408b87794 | ||
|
|
966f600d30 | ||
|
|
5df269104e | ||
|
|
97e00c6f66 | ||
|
|
ed8ce18b0f | ||
|
|
6f32d26911 |
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# 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
|
||||
@@ -45,7 +45,9 @@ jobs:
|
||||
- uses: ./.github/workflows/shared/install-yarn
|
||||
|
||||
- name: Install
|
||||
run: yarn install
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
env:
|
||||
SKIP_ONENOTE_CONVERTER_BUILD: 1
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ jobs:
|
||||
Main:
|
||||
# We always process desktop release tags, because they also publish the release
|
||||
if: github.repository == 'laurent22/joplin'
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
@@ -53,7 +53,8 @@ jobs:
|
||||
run: |
|
||||
export npm_config_arch=arm64
|
||||
export npm_config_target_arch=arm64
|
||||
yarn install
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
cd packages/app-desktop
|
||||
npm pkg set 'build.mac.artifactName'='${productName}-${version}-${arch}.${ext}'
|
||||
|
||||
|
||||
@@ -178,7 +178,8 @@ jobs:
|
||||
echo "RUNNER_ARCH=$RUNNER_ARCH"
|
||||
echo "DOCKER_IMAGE_PLATFORM=$DOCKER_IMAGE_PLATFORM"
|
||||
|
||||
yarn install
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry 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
|
||||
|
||||
@@ -25,7 +25,9 @@ jobs:
|
||||
Install-WindowsDeps
|
||||
- name: Build
|
||||
if: runner.os != 'Windows'
|
||||
run: yarn install
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry 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
|
||||
@@ -51,7 +53,9 @@ jobs:
|
||||
- name: Setup build environment
|
||||
uses: ./.github/workflows/shared/setup-build-environment
|
||||
- name: Build
|
||||
run: yarn install
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
env:
|
||||
SKIP_ONENOTE_CONVERTER_BUILD: 1
|
||||
- name: Run mobile UI tests
|
||||
|
||||
+74
-21
@@ -71,6 +71,8 @@ docs/**/*.mustache
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
packages/generator-joplin/generators/app/templates/script/publish/dist/
|
||||
|
||||
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
|
||||
packages/app-cli/app/LinkSelector.js
|
||||
packages/app-cli/app/ResourceServer.js
|
||||
@@ -83,6 +85,7 @@ packages/app-cli/app/cli-utils.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
packages/app-cli/app/command-cat.test.js
|
||||
packages/app-cli/app/command-cat.js
|
||||
packages/app-cli/app/command-clear.js
|
||||
packages/app-cli/app/command-config.js
|
||||
@@ -91,6 +94,7 @@ packages/app-cli/app/command-done.test.js
|
||||
packages/app-cli/app/command-done.js
|
||||
packages/app-cli/app/command-dump.js
|
||||
packages/app-cli/app/command-e2ee.js
|
||||
packages/app-cli/app/command-edit.test.js
|
||||
packages/app-cli/app/command-edit.js
|
||||
packages/app-cli/app/command-exit.js
|
||||
packages/app-cli/app/command-export-sync-status.js
|
||||
@@ -115,6 +119,7 @@ packages/app-cli/app/command-rmnote.test.js
|
||||
packages/app-cli/app/command-rmnote.js
|
||||
packages/app-cli/app/command-search.js
|
||||
packages/app-cli/app/command-server.js
|
||||
packages/app-cli/app/command-set.test.js
|
||||
packages/app-cli/app/command-set.js
|
||||
packages/app-cli/app/command-settingschema.js
|
||||
packages/app-cli/app/command-share.test.js
|
||||
@@ -247,6 +252,8 @@ packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
|
||||
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
|
||||
packages/app-desktop/gui/MainLayoutPane.js
|
||||
packages/app-desktop/gui/MainScreen.js
|
||||
packages/app-desktop/gui/MainScreen/handleCallbackUrl.test.js
|
||||
packages/app-desktop/gui/MainScreen/handleCallbackUrl.js
|
||||
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
|
||||
packages/app-desktop/gui/MenuBar.js
|
||||
packages/app-desktop/gui/MultiNoteActions.js
|
||||
@@ -299,6 +306,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
|
||||
@@ -315,10 +323,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/GroupNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/handlePositions.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
|
||||
packages/app-desktop/gui/NoteEditor/StatusBar.js
|
||||
packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
|
||||
@@ -361,6 +372,7 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useResourceUnwatcher.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
@@ -408,6 +420,7 @@ 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
|
||||
@@ -502,10 +515,11 @@ 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
|
||||
@@ -514,6 +528,7 @@ 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
|
||||
@@ -587,15 +602,20 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js
|
||||
packages/app-desktop/gui/utils/dragAndDrop.js
|
||||
packages/app-desktop/gui/utils/loadScript.js
|
||||
packages/app-desktop/gulpfile.js
|
||||
packages/app-desktop/integration-tests/chatPanel.spec.js
|
||||
packages/app-desktop/integration-tests/goToAnything.spec.js
|
||||
packages/app-desktop/integration-tests/main.spec.js
|
||||
packages/app-desktop/integration-tests/markdownEditor.spec.js
|
||||
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
|
||||
packages/app-desktop/integration-tests/models/ChatPanel.js
|
||||
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
|
||||
packages/app-desktop/integration-tests/models/GoToAnything.js
|
||||
packages/app-desktop/integration-tests/models/MainScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/BaseEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/EditorCodeDialog.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/MarkdownEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/NoteViewer.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/RichTextEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
@@ -695,6 +715,7 @@ packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
packages/app-mobile/components/BottomDrawer.js
|
||||
packages/app-mobile/components/BottomDrawerMenu.js
|
||||
packages/app-mobile/components/CameraView/ActionButtons.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.jest.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.js
|
||||
@@ -856,6 +877,8 @@ 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
|
||||
@@ -1111,8 +1134,6 @@ packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/openLink.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
|
||||
@@ -1143,6 +1164,8 @@ packages/editor/CodeMirror/extensions/rendering/replaceLinks.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceLinks.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeHideReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
|
||||
packages/editor/CodeMirror/extensions/searchExtension.test.js
|
||||
@@ -1161,7 +1184,10 @@ packages/editor/CodeMirror/testing/forceFullParse.js
|
||||
packages/editor/CodeMirror/testing/loadLanguages.js
|
||||
packages/editor/CodeMirror/testing/pressReleaseKey.js
|
||||
packages/editor/CodeMirror/testing/typeText.js
|
||||
packages/editor/CodeMirror/testing/visibleEditorText.js
|
||||
packages/editor/CodeMirror/theme.js
|
||||
packages/editor/CodeMirror/utils/findPositionMatchingLink.test.js
|
||||
packages/editor/CodeMirror/utils/findPositionMatchingLink.js
|
||||
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
|
||||
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.test.js
|
||||
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
|
||||
@@ -1290,9 +1316,6 @@ packages/generator-joplin/generators/app/templates/api/index.js
|
||||
packages/generator-joplin/generators/app/templates/api/noteListType.js
|
||||
packages/generator-joplin/generators/app/templates/api/types.js
|
||||
packages/generator-joplin/generators/app/templates/api_index.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
|
||||
packages/generator-joplin/generators/app/templates/src/index.js
|
||||
packages/generator-joplin/tools/updateCategories.js
|
||||
packages/htmlpack/index.test.js
|
||||
@@ -1310,6 +1333,7 @@ packages/lib/BaseModel.test.js
|
||||
packages/lib/BaseModel.js
|
||||
packages/lib/BaseSyncTarget.js
|
||||
packages/lib/Cache.js
|
||||
packages/lib/ClipperServer.test.js
|
||||
packages/lib/ClipperServer.js
|
||||
packages/lib/CssUtils.js
|
||||
packages/lib/EventDispatcher.test.js
|
||||
@@ -1380,6 +1404,7 @@ packages/lib/components/shared/SamlShared.js
|
||||
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/types.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useIsPublished.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
|
||||
packages/lib/components/shared/SsoScreenShared.js
|
||||
@@ -1394,6 +1419,7 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
|
||||
packages/lib/components/shared/dropbox-login-shared.js
|
||||
packages/lib/components/shared/note-screen-shared.test.js
|
||||
packages/lib/components/shared/note-screen-shared.js
|
||||
packages/lib/components/shared/reduxSharedMiddleware.js
|
||||
packages/lib/components/shared/side-menu-shared.test.js
|
||||
@@ -1469,6 +1495,7 @@ packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/ConflictNoteState.js
|
||||
packages/lib/models/Folder.publishing.test.js
|
||||
packages/lib/models/Folder.sharing.test.js
|
||||
packages/lib/models/Folder.test.js
|
||||
packages/lib/models/Folder.js
|
||||
@@ -1586,13 +1613,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
|
||||
@@ -1603,6 +1630,26 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
|
||||
packages/lib/services/ai/providers/TestProvider.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.js
|
||||
packages/lib/services/ai/tools/ToolIndex.js
|
||||
packages/lib/services/ai/tools/buildEditorTools.js
|
||||
packages/lib/services/ai/tools/global/createNote.js
|
||||
packages/lib/services/ai/tools/global/createNotebook.js
|
||||
packages/lib/services/ai/tools/global/deleteNote.js
|
||||
packages/lib/services/ai/tools/global/index.js
|
||||
packages/lib/services/ai/tools/global/listNotebooks.js
|
||||
packages/lib/services/ai/tools/global/listTags.js
|
||||
packages/lib/services/ai/tools/global/manageTags.js
|
||||
packages/lib/services/ai/tools/global/readImage.js
|
||||
packages/lib/services/ai/tools/global/readNote.js
|
||||
packages/lib/services/ai/tools/global/searchNotes.js
|
||||
packages/lib/services/ai/tools/global/semanticSearchNotes.js
|
||||
packages/lib/services/ai/tools/global/updateNote.js
|
||||
packages/lib/services/ai/tools/types.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.js
|
||||
packages/lib/services/ai/tools/utils/buildTool.js
|
||||
packages/lib/services/ai/tools/utils/serializeToolOutput.js
|
||||
packages/lib/services/ai/tools/utils/settings.js
|
||||
packages/lib/services/ai/types.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
@@ -1614,6 +1661,13 @@ packages/lib/services/commands/isEditorCommand.js
|
||||
packages/lib/services/commands/propsHaveChanged.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.test.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.js
|
||||
packages/lib/services/conflict/diffNotes.test.js
|
||||
packages/lib/services/conflict/diffNotes.js
|
||||
packages/lib/services/conflict/isConflictResolutionEnabled.js
|
||||
packages/lib/services/conflict/loadConflictData.test.js
|
||||
packages/lib/services/conflict/loadConflictData.js
|
||||
packages/lib/services/conflict/wordDiff.test.js
|
||||
packages/lib/services/conflict/wordDiff.js
|
||||
packages/lib/services/contextkey/contextkey.js
|
||||
packages/lib/services/database/addMigrationFile.js
|
||||
packages/lib/services/database/isSqliteSyntaxError.js
|
||||
@@ -1666,12 +1720,15 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js
|
||||
packages/lib/services/interop/InteropService_Importer_Base.js
|
||||
packages/lib/services/interop/InteropService_Importer_Custom.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToHtml.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.js
|
||||
packages/lib/services/interop/InteropService_Importer_Jex.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
|
||||
packages/lib/services/interop/InteropService_Importer_Obsidian.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Obsidian.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.js
|
||||
@@ -1693,17 +1750,6 @@ 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
|
||||
@@ -1721,7 +1767,10 @@ 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
|
||||
@@ -1824,6 +1873,7 @@ packages/lib/services/rest/routes/notes.test.js
|
||||
packages/lib/services/rest/routes/notes.js
|
||||
packages/lib/services/rest/routes/ping.js
|
||||
packages/lib/services/rest/routes/resources.js
|
||||
packages/lib/services/rest/routes/revisions.test.js
|
||||
packages/lib/services/rest/routes/revisions.js
|
||||
packages/lib/services/rest/routes/search.js
|
||||
packages/lib/services/rest/routes/tags.js
|
||||
@@ -1837,6 +1887,7 @@ packages/lib/services/rest/utils/readonlyProperties.js
|
||||
packages/lib/services/rest/utils/requestFields.js
|
||||
packages/lib/services/rest/utils/requestPaginationOptions.js
|
||||
packages/lib/services/search/SearchEngine.resources.test.js
|
||||
packages/lib/services/search/SearchEngine.semantic.test.js
|
||||
packages/lib/services/search/SearchEngine.test.js
|
||||
packages/lib/services/search/SearchEngine.js
|
||||
packages/lib/services/search/SearchEngineUtils.test.js
|
||||
@@ -1917,6 +1968,7 @@ packages/lib/shim-init-node.js
|
||||
packages/lib/shim.js
|
||||
packages/lib/string-utils.test.js
|
||||
packages/lib/string-utils.js
|
||||
packages/lib/testing/ai/semanticSearch.js
|
||||
packages/lib/testing/dom-test-environment.js
|
||||
packages/lib/testing/plugins/createTestPlugin.js
|
||||
packages/lib/testing/share/makeMockShareInvitation.js
|
||||
@@ -2072,6 +2124,7 @@ packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/eslint-rules/describeFilename.test.js
|
||||
packages/tools/fuzzer/ActionRunner.js
|
||||
packages/tools/fuzzer/Fuzzer.js
|
||||
packages/tools/fuzzer/cli.js
|
||||
@@ -2149,8 +2202,8 @@ packages/tools/updateMarkdownDoc.js
|
||||
packages/tools/utils/discourse.test.js
|
||||
packages/tools/utils/discourse.js
|
||||
packages/tools/utils/loadSponsors.js
|
||||
packages/tools/utils/parsePluralLocalizationForm.test.js
|
||||
packages/tools/utils/parsePluralLocalizationForm.js
|
||||
packages/tools/utils/parsePlurallLocalizationForm.test.js
|
||||
packages/tools/utils/translation.js
|
||||
packages/tools/validateFilenames.js
|
||||
packages/tools/website/build.js
|
||||
|
||||
+74
-21
@@ -8,6 +8,7 @@ _releases/
|
||||
**/node_modules/
|
||||
**/abcjs-basic-min.js
|
||||
packages/generator-joplin/generators/app/templates/api/
|
||||
packages/generator-joplin/generators/app/templates/script/publish/dist/
|
||||
Assets/
|
||||
docs/
|
||||
highlight.pack.js
|
||||
@@ -82,6 +83,7 @@ packages/lib/welcomeAssets.js
|
||||
packages/editor/*/vendor/
|
||||
packages/plugins/**/api
|
||||
packages/plugins/**/dist
|
||||
packages/plugin-repo-cli/dist/
|
||||
packages/server/dist/
|
||||
packages/utils/dist/
|
||||
packages/transcribe/dist/
|
||||
@@ -109,6 +111,7 @@ packages/app-cli/app/cli-utils.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
packages/app-cli/app/command-cat.test.js
|
||||
packages/app-cli/app/command-cat.js
|
||||
packages/app-cli/app/command-clear.js
|
||||
packages/app-cli/app/command-config.js
|
||||
@@ -117,6 +120,7 @@ packages/app-cli/app/command-done.test.js
|
||||
packages/app-cli/app/command-done.js
|
||||
packages/app-cli/app/command-dump.js
|
||||
packages/app-cli/app/command-e2ee.js
|
||||
packages/app-cli/app/command-edit.test.js
|
||||
packages/app-cli/app/command-edit.js
|
||||
packages/app-cli/app/command-exit.js
|
||||
packages/app-cli/app/command-export-sync-status.js
|
||||
@@ -141,6 +145,7 @@ packages/app-cli/app/command-rmnote.test.js
|
||||
packages/app-cli/app/command-rmnote.js
|
||||
packages/app-cli/app/command-search.js
|
||||
packages/app-cli/app/command-server.js
|
||||
packages/app-cli/app/command-set.test.js
|
||||
packages/app-cli/app/command-set.js
|
||||
packages/app-cli/app/command-settingschema.js
|
||||
packages/app-cli/app/command-share.test.js
|
||||
@@ -273,6 +278,8 @@ packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
|
||||
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
|
||||
packages/app-desktop/gui/MainLayoutPane.js
|
||||
packages/app-desktop/gui/MainScreen.js
|
||||
packages/app-desktop/gui/MainScreen/handleCallbackUrl.test.js
|
||||
packages/app-desktop/gui/MainScreen/handleCallbackUrl.js
|
||||
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
|
||||
packages/app-desktop/gui/MenuBar.js
|
||||
packages/app-desktop/gui/MultiNoteActions.js
|
||||
@@ -325,6 +332,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
|
||||
@@ -341,10 +349,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/GroupNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/handlePositions.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
|
||||
packages/app-desktop/gui/NoteEditor/StatusBar.js
|
||||
packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
|
||||
@@ -387,6 +398,7 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useResourceUnwatcher.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
@@ -434,6 +446,7 @@ 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
|
||||
@@ -528,10 +541,11 @@ 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
|
||||
@@ -540,6 +554,7 @@ 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
|
||||
@@ -613,15 +628,20 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js
|
||||
packages/app-desktop/gui/utils/dragAndDrop.js
|
||||
packages/app-desktop/gui/utils/loadScript.js
|
||||
packages/app-desktop/gulpfile.js
|
||||
packages/app-desktop/integration-tests/chatPanel.spec.js
|
||||
packages/app-desktop/integration-tests/goToAnything.spec.js
|
||||
packages/app-desktop/integration-tests/main.spec.js
|
||||
packages/app-desktop/integration-tests/markdownEditor.spec.js
|
||||
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
|
||||
packages/app-desktop/integration-tests/models/ChatPanel.js
|
||||
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
|
||||
packages/app-desktop/integration-tests/models/GoToAnything.js
|
||||
packages/app-desktop/integration-tests/models/MainScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/BaseEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/EditorCodeDialog.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/MarkdownEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/NoteViewer.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/RichTextEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
@@ -721,6 +741,7 @@ packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
packages/app-mobile/components/BottomDrawer.js
|
||||
packages/app-mobile/components/BottomDrawerMenu.js
|
||||
packages/app-mobile/components/CameraView/ActionButtons.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.jest.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.js
|
||||
@@ -882,6 +903,8 @@ 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
|
||||
@@ -1137,8 +1160,6 @@ packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/openLink.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
|
||||
@@ -1169,6 +1190,8 @@ packages/editor/CodeMirror/extensions/rendering/replaceLinks.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceLinks.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeHideReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
|
||||
packages/editor/CodeMirror/extensions/searchExtension.test.js
|
||||
@@ -1187,7 +1210,10 @@ packages/editor/CodeMirror/testing/forceFullParse.js
|
||||
packages/editor/CodeMirror/testing/loadLanguages.js
|
||||
packages/editor/CodeMirror/testing/pressReleaseKey.js
|
||||
packages/editor/CodeMirror/testing/typeText.js
|
||||
packages/editor/CodeMirror/testing/visibleEditorText.js
|
||||
packages/editor/CodeMirror/theme.js
|
||||
packages/editor/CodeMirror/utils/findPositionMatchingLink.test.js
|
||||
packages/editor/CodeMirror/utils/findPositionMatchingLink.js
|
||||
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
|
||||
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.test.js
|
||||
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
|
||||
@@ -1316,9 +1342,6 @@ packages/generator-joplin/generators/app/templates/api/index.js
|
||||
packages/generator-joplin/generators/app/templates/api/noteListType.js
|
||||
packages/generator-joplin/generators/app/templates/api/types.js
|
||||
packages/generator-joplin/generators/app/templates/api_index.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
|
||||
packages/generator-joplin/generators/app/templates/src/index.js
|
||||
packages/generator-joplin/tools/updateCategories.js
|
||||
packages/htmlpack/index.test.js
|
||||
@@ -1336,6 +1359,7 @@ packages/lib/BaseModel.test.js
|
||||
packages/lib/BaseModel.js
|
||||
packages/lib/BaseSyncTarget.js
|
||||
packages/lib/Cache.js
|
||||
packages/lib/ClipperServer.test.js
|
||||
packages/lib/ClipperServer.js
|
||||
packages/lib/CssUtils.js
|
||||
packages/lib/EventDispatcher.test.js
|
||||
@@ -1406,6 +1430,7 @@ packages/lib/components/shared/SamlShared.js
|
||||
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/types.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useIsPublished.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
|
||||
packages/lib/components/shared/SsoScreenShared.js
|
||||
@@ -1420,6 +1445,7 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
|
||||
packages/lib/components/shared/dropbox-login-shared.js
|
||||
packages/lib/components/shared/note-screen-shared.test.js
|
||||
packages/lib/components/shared/note-screen-shared.js
|
||||
packages/lib/components/shared/reduxSharedMiddleware.js
|
||||
packages/lib/components/shared/side-menu-shared.test.js
|
||||
@@ -1495,6 +1521,7 @@ packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/ConflictNoteState.js
|
||||
packages/lib/models/Folder.publishing.test.js
|
||||
packages/lib/models/Folder.sharing.test.js
|
||||
packages/lib/models/Folder.test.js
|
||||
packages/lib/models/Folder.js
|
||||
@@ -1612,13 +1639,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
|
||||
@@ -1629,6 +1656,26 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
|
||||
packages/lib/services/ai/providers/TestProvider.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.js
|
||||
packages/lib/services/ai/tools/ToolIndex.js
|
||||
packages/lib/services/ai/tools/buildEditorTools.js
|
||||
packages/lib/services/ai/tools/global/createNote.js
|
||||
packages/lib/services/ai/tools/global/createNotebook.js
|
||||
packages/lib/services/ai/tools/global/deleteNote.js
|
||||
packages/lib/services/ai/tools/global/index.js
|
||||
packages/lib/services/ai/tools/global/listNotebooks.js
|
||||
packages/lib/services/ai/tools/global/listTags.js
|
||||
packages/lib/services/ai/tools/global/manageTags.js
|
||||
packages/lib/services/ai/tools/global/readImage.js
|
||||
packages/lib/services/ai/tools/global/readNote.js
|
||||
packages/lib/services/ai/tools/global/searchNotes.js
|
||||
packages/lib/services/ai/tools/global/semanticSearchNotes.js
|
||||
packages/lib/services/ai/tools/global/updateNote.js
|
||||
packages/lib/services/ai/tools/types.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.js
|
||||
packages/lib/services/ai/tools/utils/buildTool.js
|
||||
packages/lib/services/ai/tools/utils/serializeToolOutput.js
|
||||
packages/lib/services/ai/tools/utils/settings.js
|
||||
packages/lib/services/ai/types.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
@@ -1640,6 +1687,13 @@ packages/lib/services/commands/isEditorCommand.js
|
||||
packages/lib/services/commands/propsHaveChanged.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.test.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.js
|
||||
packages/lib/services/conflict/diffNotes.test.js
|
||||
packages/lib/services/conflict/diffNotes.js
|
||||
packages/lib/services/conflict/isConflictResolutionEnabled.js
|
||||
packages/lib/services/conflict/loadConflictData.test.js
|
||||
packages/lib/services/conflict/loadConflictData.js
|
||||
packages/lib/services/conflict/wordDiff.test.js
|
||||
packages/lib/services/conflict/wordDiff.js
|
||||
packages/lib/services/contextkey/contextkey.js
|
||||
packages/lib/services/database/addMigrationFile.js
|
||||
packages/lib/services/database/isSqliteSyntaxError.js
|
||||
@@ -1692,12 +1746,15 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js
|
||||
packages/lib/services/interop/InteropService_Importer_Base.js
|
||||
packages/lib/services/interop/InteropService_Importer_Custom.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToHtml.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.js
|
||||
packages/lib/services/interop/InteropService_Importer_Jex.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
|
||||
packages/lib/services/interop/InteropService_Importer_Obsidian.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Obsidian.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.js
|
||||
@@ -1719,17 +1776,6 @@ 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
|
||||
@@ -1747,7 +1793,10 @@ 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
|
||||
@@ -1850,6 +1899,7 @@ packages/lib/services/rest/routes/notes.test.js
|
||||
packages/lib/services/rest/routes/notes.js
|
||||
packages/lib/services/rest/routes/ping.js
|
||||
packages/lib/services/rest/routes/resources.js
|
||||
packages/lib/services/rest/routes/revisions.test.js
|
||||
packages/lib/services/rest/routes/revisions.js
|
||||
packages/lib/services/rest/routes/search.js
|
||||
packages/lib/services/rest/routes/tags.js
|
||||
@@ -1863,6 +1913,7 @@ packages/lib/services/rest/utils/readonlyProperties.js
|
||||
packages/lib/services/rest/utils/requestFields.js
|
||||
packages/lib/services/rest/utils/requestPaginationOptions.js
|
||||
packages/lib/services/search/SearchEngine.resources.test.js
|
||||
packages/lib/services/search/SearchEngine.semantic.test.js
|
||||
packages/lib/services/search/SearchEngine.test.js
|
||||
packages/lib/services/search/SearchEngine.js
|
||||
packages/lib/services/search/SearchEngineUtils.test.js
|
||||
@@ -1943,6 +1994,7 @@ packages/lib/shim-init-node.js
|
||||
packages/lib/shim.js
|
||||
packages/lib/string-utils.test.js
|
||||
packages/lib/string-utils.js
|
||||
packages/lib/testing/ai/semanticSearch.js
|
||||
packages/lib/testing/dom-test-environment.js
|
||||
packages/lib/testing/plugins/createTestPlugin.js
|
||||
packages/lib/testing/share/makeMockShareInvitation.js
|
||||
@@ -2098,6 +2150,7 @@ packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/eslint-rules/describeFilename.test.js
|
||||
packages/tools/fuzzer/ActionRunner.js
|
||||
packages/tools/fuzzer/Fuzzer.js
|
||||
packages/tools/fuzzer/cli.js
|
||||
@@ -2175,8 +2228,8 @@ packages/tools/updateMarkdownDoc.js
|
||||
packages/tools/utils/discourse.test.js
|
||||
packages/tools/utils/discourse.js
|
||||
packages/tools/utils/loadSponsors.js
|
||||
packages/tools/utils/parsePluralLocalizationForm.test.js
|
||||
packages/tools/utils/parsePluralLocalizationForm.js
|
||||
packages/tools/utils/parsePlurallLocalizationForm.test.js
|
||||
packages/tools/utils/translation.js
|
||||
packages/tools/validateFilenames.js
|
||||
packages/tools/website/build.js
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# 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."
|
||||
+
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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
|
||||
@@ -1212,6 +1212,13 @@ footer .bottom-links-row p {
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
.price-container p.pricing-table-billed-annually {
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -1304,6 +1311,15 @@ footer .bottom-links-row p {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.cfa-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.cfa-description {
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.price-row .plan-type {
|
||||
font-size: 16px;
|
||||
@@ -1392,8 +1408,3 @@ footer .bottom-links-row p {
|
||||
:lang(zh-cn) #plans-section .faq {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.cfa-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 575 KiB |
@@ -40,12 +40,16 @@
|
||||
{{#pricingTable}}
|
||||
<strong translate>Pricing</strong>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th translate>Users</th><th translate>Price</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#rows}}
|
||||
<tr><td>{{condition}}</td><td>{{priceYearly}}</td></tr>
|
||||
<tr><td>{{condition}}</td><td>{{priceMonthly}}</td></tr>
|
||||
{{/rows}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="pricing-table-billed-annually">{{billedAnnually}}</p>
|
||||
{{/pricingTable}}
|
||||
|
||||
<p class="text-center subscribe-wrapper">
|
||||
@@ -54,6 +58,10 @@
|
||||
{{#learnMoreUrl}}
|
||||
<a id="learnMore-{{name}}" href="{{learnMoreUrl}}" class="button-link btn-white learnMoreButton cfa-button">Learn more</a>
|
||||
{{/learnMoreUrl}}
|
||||
|
||||
{{#cfaDescription}}
|
||||
<p class="cfa-description">{{cfaDescription}}</p>
|
||||
{{/cfaDescription}}
|
||||
</p>
|
||||
|
||||
{{#footnote}}<sub>(*) {{.}}</sub>{{/footnote}}
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
Joplin Server Business
|
||||
</h1>
|
||||
<p translate class="text-center sub-title">
|
||||
Joplin Server Business is a synchronisation server that you can install on your own infrastructure, so that your data remains private and secure within your business.
|
||||
Joplin Server Business is the self-hosted solution for businesses and organisations. Install it on your own infrastructure, keeping your data private and secure within your organisation while retaining full control over where it is stored.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,7 +237,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
const setHostingType = (type) => {
|
||||
const setHostingType = (type, updateUrl = true) => {
|
||||
const other = type === 'managed' ? 'self' : 'managed';
|
||||
$('.toggle-button-' + type).addClass('active');
|
||||
$('.toggle-button-' + type).removeClass('inactive');
|
||||
@@ -246,6 +246,12 @@
|
||||
|
||||
$('.hosting-type-' + type).show();
|
||||
$('.hosting-type-' + other).hide();
|
||||
|
||||
if (updateUrl) {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('hosting', type);
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
$('.toggle-button-managed').click((event) => {
|
||||
@@ -261,6 +267,6 @@
|
||||
});
|
||||
|
||||
const initialHostingType = urlQuery.get('hosting') ? urlQuery.get('hosting') : 'managed';
|
||||
setHostingType(initialHostingType);
|
||||
setHostingType(initialHostingType, false);
|
||||
</script>
|
||||
</div>
|
||||
+3
-1
@@ -102,13 +102,15 @@ 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" \
|
||||
|
||||
@@ -172,24 +172,6 @@ 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)
|
||||
@@ -197,6 +179,27 @@ 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")
|
||||
|
||||
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
# Sponsors
|
||||
|
||||
<!-- SPONSORS-ORG -->
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://socialboom.io/"><img title="Buy YouTube, Instagram, TikTok & Facebook Growth Services at Socialboom" width="256" src="https://joplinapp.org/images/sponsors/Socialboom.png" alt="Buy YouTube, Instagram, TikTok & Facebook Growth Services at Socialboom"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://socialboom.io/"><img title="Buy YouTube, Instagram, TikTok & Facebook Growth Services at Socialboom" width="256" src="https://joplinapp.org/images/sponsors/Socialboom.png" alt="Buy YouTube, Instagram, TikTok & Facebook Growth Services at Socialboom"/></a> <a href="https://essaypro.com/"><img title="write my essay EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write my essay EssayPro"/></a> <a href="https://writepaper.com/"><img title="write my paper" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="write my paper"/></a> <a href="https://mypaperhelp.com/"><img title="MyPaperHelp" width="256" src="https://joplinapp.org/images/sponsors/MyPaperHelp.png" alt="MyPaperHelp"/></a> <a href="https://essaypay.com/"><img title="EssayPay" width="256" src="https://joplinapp.org/images/sponsors/EssayPay.png" alt="EssayPay"/></a>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"nodejs": "24.12.0",
|
||||
"pkg-config": "latest",
|
||||
"python": "3.14.2",
|
||||
"python": "3.14.3",
|
||||
"bat": "latest",
|
||||
"electron": {
|
||||
"version": "latest",
|
||||
|
||||
@@ -14,6 +14,8 @@ const github = require('eslint-plugin-github');
|
||||
const { fixupPluginRules, includeIgnoreFile } = require('@eslint/compat');
|
||||
const js = require('@eslint/js');
|
||||
|
||||
const joplin = require('./packages/tools/eslint-rules');
|
||||
|
||||
const { join } = require('path');
|
||||
|
||||
// As of ESLint v9, .eslintignore is no longer supported and the presence of
|
||||
@@ -246,6 +248,7 @@ module.exports = defineConfig([{
|
||||
promise,
|
||||
jest,
|
||||
github: fixupPluginRules(github),
|
||||
joplin,
|
||||
},
|
||||
}, {
|
||||
files: [
|
||||
@@ -257,6 +260,12 @@ module.exports = defineConfig([{
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
}, {
|
||||
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.test.js', '**/*.test.jsx'],
|
||||
|
||||
rules: {
|
||||
'joplin/describe-filename': ['error'],
|
||||
},
|
||||
}, {
|
||||
// .jsx files should be linted with the default rules
|
||||
files: ['**/*.jsx'],
|
||||
|
||||
+6
-7
@@ -87,7 +87,7 @@
|
||||
"eslint-plugin-promise": "7.3.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "11.3.4",
|
||||
"fs-extra": "11.3.5",
|
||||
"glob": "11.1.0",
|
||||
"globals": "17.6.0",
|
||||
"gulp": "4.0.2",
|
||||
@@ -107,8 +107,8 @@
|
||||
},
|
||||
"packageManager": "yarn@4.16.0",
|
||||
"resolutions": {
|
||||
"@codemirror/view": "6.42.1",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/lint": "6.9.5",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
@@ -121,8 +121,6 @@
|
||||
"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",
|
||||
@@ -135,7 +133,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.29.4",
|
||||
"moment@npm:^2.8.4": "2.30.1",
|
||||
"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",
|
||||
@@ -147,6 +145,7 @@
|
||||
"@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"
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { reg } from '@joplin/lib/registry.js';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stdout can be called with formatted strings or arbitrary values
|
||||
type StdoutFn = (text: any)=> void;
|
||||
@@ -18,8 +20,9 @@ export default class BaseCommand {
|
||||
throw new Error('Usage not defined');
|
||||
}
|
||||
|
||||
public encryptionCheck(item: { encryption_applied?: number } | null) {
|
||||
public encryptionCheck(item: { encryption_applied?: number; is_locked?: number } | null) {
|
||||
if (item && item.encryption_applied) throw new Error(_('Cannot change encrypted item'));
|
||||
if (isNoteLockEnabled() && NoteLockNote.isLocked(item)) throw new Error(_('Cannot change a locked note'));
|
||||
}
|
||||
|
||||
public description(): string {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
|
||||
const Command = require('./command-cat');
|
||||
|
||||
describe('command-cat', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setupApplication();
|
||||
});
|
||||
|
||||
it('should refuse to display a locked note', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
|
||||
|
||||
let output = '';
|
||||
const command = setupCommandForTesting(Command, (text: string) => { output += text; });
|
||||
|
||||
await expect(command.action({ note: note.id, options: {} })).rejects.toThrow('locked note');
|
||||
expect(output).toBe('');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { _ } from '@joplin/lib/locale';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
@@ -23,6 +25,7 @@ class Command extends BaseCommand {
|
||||
|
||||
const item = await app().loadItem(ModelType.Note, title, { parent: app().currentFolder() });
|
||||
if (!item) throw new Error(_('Cannot find "%s".', title));
|
||||
if (isNoteLockEnabled() && NoteLockNote.isLocked(item)) throw new Error(_('Cannot display a locked note'));
|
||||
|
||||
let content = '';
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
|
||||
const Command = require('./command-edit');
|
||||
|
||||
describe('command-edit', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setupApplication();
|
||||
});
|
||||
|
||||
it('should refuse to edit a locked note without writing a temp file', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
|
||||
const tempDir = Setting.value('tempDir');
|
||||
await fs.ensureDir(tempDir);
|
||||
const filesBefore = await fs.readdir(tempDir);
|
||||
|
||||
const command = setupCommandForTesting(Command);
|
||||
|
||||
await expect(command.action({ note: note.id })).rejects.toThrow('locked note');
|
||||
expect(await fs.readdir(tempDir)).toEqual(filesBefore);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
|
||||
const Command = require('./command-set');
|
||||
|
||||
describe('command-set', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setupApplication();
|
||||
});
|
||||
|
||||
// This guard lives in BaseCommand.encryptionCheck, so it also covers attach, done, edit, ren and todo.
|
||||
it.each([
|
||||
{ label: 'refuse to change a locked note', flagEnabled: true, expectedError: 'Cannot change a locked note', expectedTitle: 'hello' },
|
||||
{ label: 'change a locked note when note lock is disabled', flagEnabled: false, expectedError: null, expectedTitle: 'renamed' },
|
||||
])('should $label', async ({ flagEnabled, expectedError, expectedTitle }) => {
|
||||
Setting.setValue('featureFlag.noteLock', flagEnabled);
|
||||
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
|
||||
|
||||
const command = setupCommandForTesting(Command);
|
||||
let error: string|null = null;
|
||||
try {
|
||||
await command.action({ note: note.id, name: 'title', value: 'renamed' });
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
|
||||
expect(error).toBe(expectedError);
|
||||
expect((await Note.load(note.id)).title).toBe(expectedTitle);
|
||||
});
|
||||
|
||||
it('should block changing the lock state of a plain note', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const note = await Note.save({ title: 'hello', body: 'plain', parent_id: '' });
|
||||
|
||||
const command = setupCommandForTesting(Command);
|
||||
await expect(command.action({ note: note.id, name: 'is_locked', value: '1' })).rejects.toThrow('The note lock state cannot be changed from the command line');
|
||||
expect((await Note.load(note.id)).is_locked).toBe(0);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { _ } from '@joplin/lib/locale';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Database from '@joplin/lib/database';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
@@ -34,6 +35,11 @@ class Command extends BaseCommand {
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
this.encryptionCheck(notes[i]);
|
||||
|
||||
// Enabling the lock here would only fail deep inside the gated save, so refuse it outright.
|
||||
if (isNoteLockEnabled() && propName === 'is_locked') {
|
||||
throw new Error(_('The note lock state cannot be changed from the command line'));
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
|
||||
const newNote: Record<string, unknown> = {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"chalk": "4.1.2",
|
||||
"compare-version": "0.1.2",
|
||||
"file-type": "16.5.4",
|
||||
"fs-extra": "11.3.4",
|
||||
"fs-extra": "11.3.5",
|
||||
"html-entities": "1.4.0",
|
||||
"keytar": "7.9.0",
|
||||
"md5": "2.3.0",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
|
||||
<en-export export-date="20260805T200517Z" application="Evernote" version="11.28.2">
|
||||
<note>
|
||||
<title>Note 2</title>
|
||||
<created>20260805T185318Z</created>
|
||||
<updated>20260805T200333Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>This is a test.</div><div><br/></div><div><span style="--en-highlight:red;background-color: #ffe2d5;">Link to a note in another notebook</span>: <a type="inline-richlink" rev="{"isCached":false}" href="https://share.evernote.com/note/3784df9c-5e9f-dd33-c246-a6e9616db99b">Test</a></div><div>Link to a note in the same notebook: <a type="inline-richlink" rev="{"isCached":false}" href="https://share.evernote.com/note/471e9dd8-9436-276a-0594-6d3f5a006084">Example note</a></div></en-note> ]]>
|
||||
</content>
|
||||
</note>
|
||||
<note>
|
||||
<title>Example note</title>
|
||||
<created>20260805T200313Z</created>
|
||||
<updated>20260805T200320Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>Testing!</div></en-note> ]]>
|
||||
</content>
|
||||
</note>
|
||||
</en-export>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
|
||||
<en-export export-date="20260805T200508Z" application="Evernote" version="11.28.2">
|
||||
<note>
|
||||
<title>Test</title>
|
||||
<created>20260805T184526Z</created>
|
||||
<updated>20260805T200439Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div><br/></div><div><a type="inline-richlink" rev="{"isCached":false}" href="evernote:///view/312260475/s710/0c61536b-4d58-e7ee-1070-7a1a50d94852/e312cdf9-8314-440f-91a6-4bfca42da635">Test note</a>. <a type="inline-richlink" rev="{"isCached":false}" href="https://share.evernote.com/note/471e9dd8-9436-276a-0594-6d3f5a006084">Example note</a></div></en-note> ]]>
|
||||
</content>
|
||||
</note>
|
||||
<note>
|
||||
<title>Testing</title>
|
||||
<created>20260805T185202Z</created>
|
||||
<updated>20260805T200413Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div><a rev="en_rl_none" href="https://share.evernote.com/note/3784df9c-5e9f-dd33-c246-a6e9616db99b">Test</a>...</div><div><br/></div><div><br/></div></en-note> ]]>
|
||||
</content>
|
||||
</note>
|
||||
<note>
|
||||
<title>Test note</title>
|
||||
<created>20260805T184449Z</created>
|
||||
<updated>20260805T184526Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>This is a test note!</div><ul><li><div>Test</div></li></ul><div><br/></div><div><br/></div></en-note> ]]>
|
||||
</content>
|
||||
</note>
|
||||
</en-export>
|
||||
@@ -1 +1 @@
|
||||
<a data-from-md href='#'>test</a>
|
||||
<a>test</a>
|
||||
@@ -146,13 +146,14 @@ 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', editsApplied: 1, editsMissed: 0 },
|
||||
{ id: 'id-1', role: 'assistant', text: 'Testing' },
|
||||
]);
|
||||
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ export interface AiChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'error' | 'separator';
|
||||
text: string;
|
||||
editsApplied?: number;
|
||||
editsMissed?: number;
|
||||
hide?: boolean;
|
||||
|
||||
// The raw message(s) corresponding to this event
|
||||
raw: ChatMessage[];
|
||||
@@ -317,15 +316,8 @@ 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,
|
||||
|
||||
@@ -772,6 +772,15 @@ 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,6 +3,7 @@ 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 = {
|
||||
@@ -18,11 +19,14 @@ 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',
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && !noteIsLocked',
|
||||
};
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export const runtime = (): CommandRuntime => {
|
||||
void CommandService.instance().execute('startExternalEditing', noteId);
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && (!modalDialogVisible || gotoAnythingVisible)',
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && !noteIsLocked && (!modalDialogVisible || gotoAnythingVisible)',
|
||||
mapStateToTitle: (state: AppState) => {
|
||||
const noteId = stateUtils.selectedNoteId(state);
|
||||
return state.watchedNoteFiles.includes(noteId) ? _('Stop') : '';
|
||||
|
||||
@@ -2,17 +2,45 @@ import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { AiChatMessage } from '../../app.reducer';
|
||||
import InlineMarkdownDisplay from '../InlineMarkdownDisplay';
|
||||
import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types';
|
||||
import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/types';
|
||||
|
||||
|
||||
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);
|
||||
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);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -27,7 +55,7 @@ const ChatMessageItem: React.FC<Props> = ({ message }) => {
|
||||
return <div className='error'>{message.text}</div>;
|
||||
}
|
||||
|
||||
const summary = message.role === 'assistant' ? editsSummary(message.raw, message.editsApplied ?? 0, message.editsMissed ?? 0) : '';
|
||||
const summary = message.role === 'assistant' ? toolResultSummary(message.raw) : '';
|
||||
// Always show something in the message box:
|
||||
const textContent = !message.text && !summary ? _('(no message)') : message.text;
|
||||
|
||||
@@ -44,7 +72,7 @@ const ChatMessageItem: React.FC<Props> = ({ message }) => {
|
||||
{content}
|
||||
{summary && (
|
||||
<div className='meta'>
|
||||
{(message.editsMissed ?? 0) > 0
|
||||
{hasToolError(message.raw)
|
||||
? <span className='warning'>{summary}</span>
|
||||
: <span>{summary}</span>}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ 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');
|
||||
|
||||
@@ -142,11 +143,13 @@ 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: _('— now viewing: %s —', props.noteTitle || _('(untitled)')),
|
||||
raw: [],
|
||||
text,
|
||||
raw: [{ role: ChatRole.User, content: _('Switched notes: %s', text) }],
|
||||
});
|
||||
}, [props.noteId, props.noteTitle, appendMessage]);
|
||||
|
||||
@@ -188,7 +191,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 hadSuccessfulResponse = false;
|
||||
let hadVisibleResponse = false;
|
||||
appendMessage({
|
||||
id: userTurnId,
|
||||
role: 'user',
|
||||
@@ -217,6 +220,8 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
body: note.body,
|
||||
title: note.title,
|
||||
selection,
|
||||
noteId: note.id,
|
||||
folderId: note.parent_id,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -236,15 +241,16 @@ 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],
|
||||
});
|
||||
}
|
||||
@@ -295,7 +301,7 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
logger.warn('Chat failed:', error);
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
if (!hadSuccessfulResponse) {
|
||||
if (!hadVisibleResponse) {
|
||||
dispatch({ type: 'AI_CHAT_REMOVE', windowId, id: userTurnId });
|
||||
setInput(text);
|
||||
}
|
||||
@@ -316,6 +322,10 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
dispatch({ type: 'AI_CHAT_RESET', windowId: windowId });
|
||||
}, [dispatch, windowId, cancelRequest]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void CommandService.instance().executeInWindow('toggleAiChat', { windowId: windowId, args: [] });
|
||||
}, [windowId]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Don't send while an IME composition is in flight — Enter commits
|
||||
// the composition for CJK / accented input.
|
||||
@@ -343,15 +353,24 @@ 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}>
|
||||
{messages.length === 0 && (
|
||||
{visibleMessages.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>
|
||||
)}
|
||||
{messages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
|
||||
{visibleMessages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className='composer'>
|
||||
@@ -385,13 +404,28 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</>;
|
||||
|
||||
return { content, showingMessages: messages.length > 0 };
|
||||
return { content, showingMessages: visibleMessages.length > 0 };
|
||||
};
|
||||
|
||||
const sendButtonLabel = sending ? _('Stop generating') : _('Send');
|
||||
const headerId = useId();
|
||||
const { content, showingMessages } = renderContent();
|
||||
|
||||
const renderHeaderActions = () => {
|
||||
if (showingMessages) {
|
||||
return <button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>;
|
||||
}
|
||||
|
||||
const closeLabel = _('Close');
|
||||
return <button
|
||||
type='button'
|
||||
className='close'
|
||||
onClick={handleClose}
|
||||
title={closeLabel}
|
||||
aria-label={closeLabel}
|
||||
><i className='fas fa-times' role='img' aria-hidden={true}/></button>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='chat-panel'
|
||||
@@ -400,11 +434,9 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<div className='header'>
|
||||
<div className='header chat-panel-header'>
|
||||
<h1 className='title' id={headerId}>{_('AI Chat')}</h1>
|
||||
{showingMessages && (
|
||||
<button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>
|
||||
)}
|
||||
{renderHeaderActions()}
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
@@ -19,23 +19,25 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-panel > .header > .title {
|
||||
flex: 1;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
.chat-panel-header {
|
||||
> .title {
|
||||
flex: 1;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-panel > .header > .reset {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--joplin-color-faded);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
> .reset, > .close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--joplin-color-faded);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
padding: 2px 6px;
|
||||
|
||||
.chat-panel > .header > .reset:hover {
|
||||
color: var(--joplin-color);
|
||||
&:hover {
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-panel > .degraded-status {
|
||||
|
||||
@@ -7,7 +7,7 @@ import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
import dialogs from '../dialogs';
|
||||
import { decryptedStatText, determineKeyPassword, dontReencryptData, enableEncryptionConfirmationMessages, onSavePasswordClick, onToggleEnabledClick, reencryptData, upgradeMasterKey, useInputPasswords, useNeedMasterPassword, usePasswordChecker, useStats, useToggleShowDisabledMasterKeys } from '@joplin/lib/components/EncryptionConfigScreen/utils';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { getEncryptionEnabled, localSyncInfoSelector, masterKeyEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { getDefaultMasterKey, getMasterPasswordStatusMessage, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
@@ -606,7 +606,7 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const syncInfo = new SyncInfo(state.settings['syncInfoCache']);
|
||||
const syncInfo = localSyncInfoSelector(state);
|
||||
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
|
||||
@@ -27,7 +27,8 @@ 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, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import executeCallbackUrl from './MainScreen/handleCallbackUrl';
|
||||
import ElectronAppWrapper from '../ElectronAppWrapper';
|
||||
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
@@ -38,7 +39,6 @@ import TrashNotification from './TrashNotification/TrashNotification';
|
||||
import UpdateNotification from './UpdateNotification/UpdateNotification';
|
||||
import PluginNotification from './PluginNotification/PluginNotification';
|
||||
import { Toast } from '@joplin/lib/services/plugins/api/types';
|
||||
import QuitSyncDialog from './QuitSyncDialog';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import checkForUpdates, { isReleaseVersion } from '../checkForUpdates';
|
||||
|
||||
@@ -153,20 +153,23 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
||||
ipcRenderer.on('asynchronous-message', (_event: import('electron').IpcRendererEvent, message: string, args: { url: string }) => {
|
||||
if (message === 'openCallbackUrl') {
|
||||
this.openCallbackUrl(args.url);
|
||||
void this.openCallbackUrl(args.url);
|
||||
}
|
||||
});
|
||||
|
||||
const initialCallbackUrl = (bridge().electronApp() as ElectronAppWrapper).initialCallbackUrl();
|
||||
if (initialCallbackUrl) {
|
||||
this.openCallbackUrl(initialCallbackUrl);
|
||||
void this.openCallbackUrl(initialCallbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
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 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 updateLayoutPluginViews(layout: LayoutItem, plugins: PluginStates) {
|
||||
@@ -182,6 +185,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
if (!existingItem) {
|
||||
draftLayout.children.push({
|
||||
key: viewId,
|
||||
visible: info.view.opened,
|
||||
context: {
|
||||
pluginId: info.plugin.id,
|
||||
},
|
||||
@@ -798,7 +802,6 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
themeId={this.props.themeId}
|
||||
toast={this.props.toast}
|
||||
/>
|
||||
<QuitSyncDialog themeId={this.props.themeId} />
|
||||
{messageComp}
|
||||
{layoutComp}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { utils as commandUtils } from '@joplin/lib/services/CommandService';
|
||||
import executeCallbackUrl from './handleCallbackUrl';
|
||||
import { withWarningSilenced } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
const mockOpenExternal = jest.fn();
|
||||
|
||||
jest.mock('../../services/bridge', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
openExternal: (url: string) => mockOpenExternal(url),
|
||||
}),
|
||||
}));
|
||||
|
||||
const setSelectedNoteId = (noteId: string | null) => {
|
||||
// executeCallbackUrl reads the selected note id via commandUtils.store; a
|
||||
// null id makes handleGetCurrentNote take its x-error branch, which routes
|
||||
// the caller-supplied target through respond() without touching the DB.
|
||||
commandUtils.store = {
|
||||
getState: () => ({ selectedNoteIds: noteId ? [noteId] : [] }),
|
||||
};
|
||||
};
|
||||
|
||||
describe('handleCallbackUrl', () => {
|
||||
beforeEach(() => {
|
||||
mockOpenExternal.mockClear();
|
||||
setSelectedNoteId(null);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'x-custom-scheme://arbitrary-scheme-reached',
|
||||
'file:///etc/passwd',
|
||||
'ms-msdt://something',
|
||||
'mailto:someone@example.com',
|
||||
'not a url',
|
||||
])('should not dispatch a callback target with a disallowed scheme (%s)', async (target) => {
|
||||
await withWarningSilenced(/Rejected malformed callback|Rejected callback target with disallowed scheme/, async () => {
|
||||
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
|
||||
}, { requireWarning: true });
|
||||
expect(mockOpenExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
'http://example.com/cb',
|
||||
'https://example.com/cb',
|
||||
])('should dispatch a callback target with an allowed scheme (%s)', async (target) => {
|
||||
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
|
||||
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenExternal.mock.calls[0][0]).toContain(`${target}?`);
|
||||
});
|
||||
|
||||
it('should not leak the raw error message on the error path', async () => {
|
||||
// Force a throw inside the try block so the catch path builds the response.
|
||||
commandUtils.store = {
|
||||
getState: () => { throw new Error('secret path /home/victim/.config/joplin/database.sqlite'); },
|
||||
};
|
||||
|
||||
await withWarningSilenced(/Error handling callback URL command "getCurrentNote":.*secret path \/home\/victim/, async () => {
|
||||
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent('https://example.com/cb')}`);
|
||||
}, { requireWarning: true });
|
||||
|
||||
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
|
||||
const responseUrl = mockOpenExternal.mock.calls[0][0];
|
||||
expect(responseUrl).not.toContain('secret path');
|
||||
expect(responseUrl).not.toContain('database.sqlite');
|
||||
expect(new URL(responseUrl).searchParams.get('errorMessage')).toBe('The command could not be completed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { CallbackUrlCommand, getNoteCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import CommandService, { utils as commandUtils } from '@joplin/lib/services/CommandService';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import bridge from '../../services/bridge';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('handleCallbackUrl');
|
||||
|
||||
// Opens the caller's x-success / x-error URL with the result appended as query params.
|
||||
const respond = (target: string, params: Record<string, string> = {}) => {
|
||||
if (!target) return;
|
||||
|
||||
// The callback target is untrusted input from the x-callback-url. Anything
|
||||
// that is not file: is handed straight to shell.openExternal(), i.e. the OS
|
||||
// URI dispatcher, so restrict it to schemes we're willing to invoke. This
|
||||
// also covers the x-error path below, which fires on any thrown exception.
|
||||
let protocol;
|
||||
try {
|
||||
protocol = new URL(target).protocol;
|
||||
} catch (error) {
|
||||
// Don't log the caller-supplied target: it's untrusted and may contain
|
||||
// secrets in its query or fragment.
|
||||
logger.warn('Rejected malformed callback target');
|
||||
return;
|
||||
}
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
logger.warn(`Rejected callback target with disallowed scheme "${protocol}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
|
||||
const fragmentIndex = target.indexOf('#');
|
||||
const base = fragmentIndex === -1 ? target : target.substring(0, fragmentIndex);
|
||||
const fragment = fragmentIndex === -1 ? '' : target.substring(fragmentIndex);
|
||||
const separator = base.includes('?') ? '&' : '?';
|
||||
const responseUrl = query ? `${base}${separator}${query}${fragment}` : target;
|
||||
void bridge().openExternal(responseUrl);
|
||||
};
|
||||
|
||||
const handleGetCurrentNote = async (params: Record<string, string>) => {
|
||||
const state = commandUtils.store.getState() as AppState;
|
||||
const noteId = stateUtils.selectedNoteId(state);
|
||||
if (!noteId) {
|
||||
respond(params['x-error'], { errorMessage: 'No note is currently selected' });
|
||||
return;
|
||||
}
|
||||
|
||||
const note = await Note.load(noteId);
|
||||
if (!note) {
|
||||
respond(params['x-error'], { errorMessage: 'The selected note could not be loaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
respond(params['x-success'], {
|
||||
title: note.title,
|
||||
url: getNoteCallbackUrl(note.id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateNote = async (params: Record<string, string>) => {
|
||||
const folder = await Folder.getValidActiveFolder();
|
||||
if (!folder) {
|
||||
respond(params['x-error'], { errorMessage: 'No valid notebook is available to create the note in' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Not provisional: an externally-requested note should persist even if unedited.
|
||||
const note = await Note.save({
|
||||
...Note.previewFieldsWithDefaultValues({ includeTimestamps: false }),
|
||||
title: params.title ?? '',
|
||||
body: params.body ?? '',
|
||||
parent_id: folder.id,
|
||||
});
|
||||
|
||||
commandUtils.store.dispatch({ type: 'NOTE_SELECT', id: note.id });
|
||||
|
||||
respond(params['x-success'], {
|
||||
title: note.title,
|
||||
url: getNoteCallbackUrl(note.id),
|
||||
});
|
||||
};
|
||||
|
||||
// Verbs dispatched directly to CommandService by name. Kept as an explicit
|
||||
// allowlist (not a catch-all) so a crafted URL can't run arbitrary commands. See 69826610a.
|
||||
const openCommands: string[] = [
|
||||
CallbackUrlCommand.OpenNote,
|
||||
CallbackUrlCommand.OpenFolder,
|
||||
CallbackUrlCommand.OpenTag,
|
||||
];
|
||||
|
||||
const executeCallbackUrl = async (url: string) => {
|
||||
const info = parseCallbackUrl(url);
|
||||
|
||||
if (!openCommands.includes(info.command) &&
|
||||
info.command !== CallbackUrlCommand.GetCurrentNote &&
|
||||
info.command !== CallbackUrlCommand.CreateNote) {
|
||||
throw new Error(`Unhandled callback URL command: ${info.command}`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (info.command === CallbackUrlCommand.GetCurrentNote) {
|
||||
await handleGetCurrentNote(info.params);
|
||||
} else if (info.command === CallbackUrlCommand.CreateNote) {
|
||||
await handleCreateNote(info.params);
|
||||
} else {
|
||||
await CommandService.instance().execute(info.command.toString(), info.params.id);
|
||||
logger.info(`Executed callback URL command: ${info.command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error handling callback URL command "${info.command}":`, error);
|
||||
// Return an opaque status rather than error.message, which can leak
|
||||
// profile paths and item ids to the caller-supplied x-error target.
|
||||
respond(info.params['x-error'], { errorMessage: 'The command could not be completed' });
|
||||
}
|
||||
};
|
||||
|
||||
export default executeCallbackUrl;
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
@@ -864,10 +865,15 @@ 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,
|
||||
|
||||
+16
-13
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
const logger = Logger.create('useEditorSearch');
|
||||
|
||||
@@ -14,9 +15,8 @@ interface SetMarkersOptions {
|
||||
showEditorMarkers?: boolean;
|
||||
withSelection?: boolean;
|
||||
}
|
||||
type Keyword = { value: string };
|
||||
|
||||
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: Keyword[], options: SetMarkersOptions)=> number;
|
||||
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: HighlightedWord[], options: SetMarkersOptions)=> number;
|
||||
|
||||
|
||||
// Modified from codemirror/addons/search/search.js
|
||||
@@ -109,11 +109,11 @@ export default function useEditorSearchExtension() {
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
|
||||
function escapeRegExp(keyword: string) {
|
||||
return keyword.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
return keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
|
||||
function getSearchTerm(keyword: Keyword) {
|
||||
const value = escapeRegExp(keyword.value);
|
||||
function getSearchTerm(keyword: HighlightedWord) {
|
||||
const value = escapeRegExp(typeof keyword === 'string' ? keyword : keyword.value);
|
||||
return new RegExp(value, 'gi');
|
||||
}
|
||||
|
||||
@@ -139,6 +139,8 @@ export default function useEditorSearchExtension() {
|
||||
|
||||
clearMarkers();
|
||||
|
||||
const keywordValue = (keyword: HighlightedWord) => typeof keyword === 'string' ? keyword : keyword.value;
|
||||
|
||||
// HIGHLIGHT KEYWORDS
|
||||
// When doing a global search it's possible to have multiple keywords
|
||||
// This means we need to highlight each one
|
||||
@@ -146,12 +148,12 @@ export default function useEditorSearchExtension() {
|
||||
for (let i = 0; i < keywords.length; i++) {
|
||||
const keyword = keywords[i];
|
||||
|
||||
if (keyword.value === '') continue;
|
||||
if (keywordValue(keyword) === '') continue;
|
||||
|
||||
const searchTerm = getSearchTerm(keyword);
|
||||
|
||||
// We only want to scroll the first keyword into view in the case of a multi keyword search
|
||||
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
|
||||
const scrollTo = i === 0 && (previousKeywordValue !== keywordValue(keyword) || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
|
||||
|
||||
try {
|
||||
const match = highlightSearch(cm, searchTerm, options.selectedIndex, scrollTo, !!options.withSelection);
|
||||
@@ -172,15 +174,16 @@ export default function useEditorSearchExtension() {
|
||||
setPreviousSearchTimestamp(options.searchTimestamp);
|
||||
|
||||
// SEARCHOVERLAY
|
||||
// We only want to highlight all matches when there is only 1 search term
|
||||
if (keywords.length !== 1 || keywords[0].value === '') {
|
||||
// We only want to highlight all matches when there is only 1 main search term
|
||||
const mainSearch = keywords.filter(keyword => typeof keyword === 'string' || keyword.source !== 'semantic');
|
||||
if (mainSearch.length !== 1 || keywordValue(mainSearch[0]) === '') {
|
||||
clearOverlay(cm);
|
||||
const prev = keywords.length > 1 ? keywords[0].value : '';
|
||||
const prev = keywords.length > 1 ? keywordValue(keywords[0]) : '';
|
||||
setPreviousKeywordValue(prev);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const searchTerm = getSearchTerm(keywords[0]);
|
||||
const searchTerm = getSearchTerm(mainSearch[0]);
|
||||
|
||||
// Determine the number of matches in the source, this is passed on
|
||||
// to the NoteEditor component
|
||||
@@ -189,10 +192,10 @@ export default function useEditorSearchExtension() {
|
||||
|
||||
// Don't bother clearing and re-calculating the overlay if the search term
|
||||
// hasn't changed
|
||||
if (keywords[0].value === previousKeywordValue) return nMatches;
|
||||
if (keywordValue(keywords[0]) === previousKeywordValue) return nMatches;
|
||||
|
||||
clearOverlay(cm);
|
||||
setPreviousKeywordValue(keywords[0].value);
|
||||
setPreviousKeywordValue(keywordValue(keywords[0]));
|
||||
|
||||
// These operations are pretty slow, so we won't add use them until the user
|
||||
// has finished typing, 500ms is probably enough time
|
||||
|
||||
@@ -710,6 +710,14 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [renderedBody, webviewReady]);
|
||||
|
||||
// Stop media playing in the viewer once it's hidden - https://github.com/laurent22/joplin/issues/15278
|
||||
const viewerVisible = props.visiblePanes.includes('viewer');
|
||||
useEffect(() => {
|
||||
if (!viewerVisible) {
|
||||
webviewRef.current?.stopMediaPlayback();
|
||||
}
|
||||
}, [viewerVisible]);
|
||||
|
||||
const { onSetInitialMarkersRef } = useEditorSearchHandler({
|
||||
setLocalSearchResultCount: props.setLocalSearchResultCount,
|
||||
searchMarkers: props.searchMarkers,
|
||||
|
||||
@@ -297,6 +297,14 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
|
||||
useRefocusOnVisiblePaneChange({ editorRef, webviewRef, visiblePanes: props.visiblePanes });
|
||||
|
||||
// Stop media playing in the viewer once it's hidden - https://github.com/laurent22/joplin/issues/15278
|
||||
const viewerVisible = props.visiblePanes.includes('viewer');
|
||||
useEffect(() => {
|
||||
if (!viewerVisible) {
|
||||
webviewRef.current?.stopMediaPlayback();
|
||||
}
|
||||
}, [viewerVisible]);
|
||||
|
||||
useEditorSearchHandler({
|
||||
setLocalSearchResultCount: props.setLocalSearchResultCount,
|
||||
searchMarkers: props.searchMarkers,
|
||||
|
||||
@@ -109,7 +109,11 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
|
||||
}
|
||||
|
||||
const searchState = editor.getSearchState();
|
||||
const externalSearchText = props.externalSearch.keywords.map(k => k.value).join(' ') || searchState.searchText;
|
||||
const externalSearchText = props.externalSearch
|
||||
.keywords
|
||||
.filter(k => typeof k === 'string' || k.source !== 'semantic')
|
||||
.map(k => typeof k === 'string' ? k : k.value)
|
||||
.join(' ') || searchState.searchText;
|
||||
|
||||
if (externalSearchText === searchState.searchText && searchState.dialogVisible === props.useLocalSearch) {
|
||||
return;
|
||||
|
||||
@@ -25,4 +25,17 @@ export default () => ({
|
||||
'replaced $ matches': _('replaced $ matches'),
|
||||
'replaced match on line $': _('replaced match on line $'),
|
||||
'on line': _('on line'),
|
||||
|
||||
// Table editor context menu. These keys must be kept in sync with the
|
||||
// state.phrase() calls in packages/editor renderTables.ts.
|
||||
'Insert row above': _('Insert row above'),
|
||||
'Insert row below': _('Insert row below'),
|
||||
'Insert column left': _('Insert column left'),
|
||||
'Insert column right': _('Insert column right'),
|
||||
'Move row up': _('Move row up'),
|
||||
'Move row down': _('Move row down'),
|
||||
'Move column left': _('Move column left'),
|
||||
'Move column right': _('Move column right'),
|
||||
'Delete row': _('Delete row'),
|
||||
'Delete column': _('Delete column'),
|
||||
});
|
||||
@@ -33,7 +33,8 @@ import { DropHandler } from '../../utils/useDropHandler';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import useWebViewApi from './utils/useWebViewApi';
|
||||
import useLinkTooltips from './utils/useLinkTooltips';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { blur, focus } from '@joplin/lib/utils/focusHandler';
|
||||
import useHighlightedSearchTerms from './utils/useHighlightedSearchTerms';
|
||||
const md5 = require('md5');
|
||||
import { clipboard } from 'electron';
|
||||
const supportedLocales = require('./supportedLocales');
|
||||
@@ -186,6 +187,9 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
if (!editorRef.current) return '';
|
||||
return prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editorRef.current.getContent(), props.contentOriginalCss);
|
||||
},
|
||||
blurEditor: () => {
|
||||
if (editor) blur('TinyMCE::reload', editor.getBody());
|
||||
},
|
||||
resetScroll: () => {
|
||||
if (editor) editor.getWin().scrollTo(0, 0);
|
||||
},
|
||||
@@ -1199,6 +1203,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
};
|
||||
}, [editor, onEditorContentClick]);
|
||||
|
||||
useHighlightedSearchTerms(editor, props.searchMarkers.keywords, props.themeId);
|
||||
|
||||
// This is to handle dropping notes on the editor. In this case, we add an
|
||||
// overlay over the editor, which makes it a valid drop target. This in
|
||||
// turn makes NoteEditor get the drop event and dispatch it.
|
||||
@@ -1243,6 +1249,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
// https://github.com/facebook/react/issues/14010#issuecomment-433788147
|
||||
const props_onChangeRef = useRef<NoteBodyEditorProps['onChange']>(null);
|
||||
props_onChangeRef.current = props.onChange;
|
||||
const editorNoteReloadTimeRequestRef = useRef(props.editorNoteReloadTimeRequest);
|
||||
editorNoteReloadTimeRequestRef.current = props.editorNoteReloadTimeRequest;
|
||||
|
||||
const prop_htmlToMarkdownRef = useRef<HtmlToMarkdownHandler>(null);
|
||||
prop_htmlToMarkdownRef.current = props.htmlToMarkdown;
|
||||
@@ -1255,12 +1263,14 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
if (!info) return;
|
||||
|
||||
nextOnChangeEventInfo.current = null;
|
||||
if (info.editorNoteReloadTimeRequest !== editorNoteReloadTimeRequestRef.current) return;
|
||||
|
||||
resetLinkTooltips();
|
||||
const contentMd = await prop_htmlToMarkdownRef.current(info.contentMarkupLanguage, info.editor.getContent(), info.contentOriginalCss);
|
||||
|
||||
lastOnChangeEventInfo.current.content = contentMd;
|
||||
lastOnChangeEventInfo.current.resourceInfos = await attachedResources(contentMd);
|
||||
if (info.editorNoteReloadTimeRequest !== editorNoteReloadTimeRequestRef.current) return;
|
||||
|
||||
props_onChangeRef.current({
|
||||
changeId: info.changeId,
|
||||
@@ -1301,6 +1311,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor: editor,
|
||||
contentMarkupLanguage: props.contentMarkupLanguage,
|
||||
contentOriginalCss: props.contentOriginalCss,
|
||||
editorNoteReloadTimeRequest: editorNoteReloadTimeRequestRef.current,
|
||||
};
|
||||
|
||||
onChangeHandlerTimeoutRef.current = shim.setTimeout(async () => {
|
||||
@@ -1547,6 +1558,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.on(TinyMceEditorEvents.JoplinChange, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.Undo, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.Redo, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.FormatApply, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.FormatRemove, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.ExecCommand, onExecCommand);
|
||||
editor.on(TinyMceEditorEvents.SetAttrib, onSetAttrib);
|
||||
editor.on('TableModified', onTableModified);
|
||||
@@ -1564,6 +1577,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.off(TinyMceEditorEvents.JoplinChange, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.Undo, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.Redo, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.FormatApply, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.FormatRemove, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.ExecCommand, onExecCommand);
|
||||
editor.off(TinyMceEditorEvents.SetAttrib, onSetAttrib);
|
||||
editor.off('TableModified', onTableModified);
|
||||
|
||||
@@ -15,6 +15,8 @@ export enum TinyMceEditorEvents {
|
||||
Redo = 'Redo',
|
||||
ExecCommand = 'ExecCommand',
|
||||
SetAttrib = 'SetAttrib',
|
||||
FormatApply = 'FormatApply',
|
||||
FormatRemove = 'FormatRemove',
|
||||
}
|
||||
|
||||
export type DispatchDidUpdateCallback = (editor: Editor)=> void;
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Editor, EditorEvent } from 'tinymce';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Highlight: typeof Highlight;
|
||||
Range: typeof Range;
|
||||
CSS: typeof CSS;
|
||||
}
|
||||
}
|
||||
|
||||
const useSearchRegexes = (searchTerms: HighlightedWord[]) => {
|
||||
return useMemo(() => {
|
||||
return searchTerms.map((term: HighlightedWord) => {
|
||||
let text;
|
||||
if (typeof term === 'string') {
|
||||
text = term;
|
||||
} else {
|
||||
text = term.value;
|
||||
}
|
||||
|
||||
// Only highlight short matches at word boundaries (roughly matching the note viewer
|
||||
// behavior)
|
||||
const regexText = SearchEngine.instance().queryTermToRegex(text, { anchorToWordBoundary: text.length <= 2 });
|
||||
return regexText ? new RegExp(regexText, 'ig') : null;
|
||||
}).filter(regex => !!regex);
|
||||
}, [searchTerms]);
|
||||
};
|
||||
|
||||
const searchHighlightKey = 'jop-search-highlight';
|
||||
|
||||
const useHighlightStyleSheet = (editor: Editor, themeId: number) => {
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const theme: Theme = themeStyle(themeId);
|
||||
const style = editor.dom.create('style', {}, `
|
||||
/* Avoid showing highlights when the user has the find dialog open and
|
||||
there are TinyMCE-provided search results */
|
||||
body:not(:has(span.mce-match-marker)) {
|
||||
::highlight(${searchHighlightKey}) {
|
||||
background-color: ${theme.searchMarkerBackgroundColor};
|
||||
color: ${theme.searchMarkerColor};
|
||||
}
|
||||
}
|
||||
`);
|
||||
editor.getDoc().head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
style.remove();
|
||||
};
|
||||
}, [editor, themeId]);
|
||||
};
|
||||
|
||||
const useHighlighter = (editor: Editor, searchRegexes: RegExp[]) => {
|
||||
return useMemo(() => {
|
||||
if (!editor) {
|
||||
return { canHighlight: false };
|
||||
}
|
||||
|
||||
const editorWindow = editor.getWin();
|
||||
const ranges: Map<Node, Range[]> = new Map();
|
||||
let highlight: Highlight = undefined;
|
||||
|
||||
const processNode = (node: Node) => {
|
||||
ranges.delete(node);
|
||||
|
||||
type FoundChildAndOffset = { node: Node; offset: number };
|
||||
const findChildAtTextOffset = (parent: Node, offset: number): FoundChildAndOffset => {
|
||||
if (offset > parent.textContent.length) return null;
|
||||
if (parent.nodeName === '#text') {
|
||||
return { node: parent, offset };
|
||||
}
|
||||
|
||||
let start = 0;
|
||||
for (const child of parent.childNodes) {
|
||||
if (child.nodeName === '#comment') continue;
|
||||
|
||||
const found = findChildAtTextOffset(child, offset - start);
|
||||
start += child.textContent.length;
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildRange = (parent: Node, startIndex: number, endIndex: number) => {
|
||||
const range: Range = new editorWindow.Range();
|
||||
const start = findChildAtTextOffset(parent, startIndex);
|
||||
const end = findChildAtTextOffset(parent, endIndex);
|
||||
range.setStart(start?.node ?? parent, start?.offset ?? 0);
|
||||
range.setEnd(end?.node ?? parent, end?.offset ?? parent.textContent.length);
|
||||
return range;
|
||||
};
|
||||
|
||||
// Process highlights at the paragraph level, where possible, to pick up formatting that crosses Markdown boundaries
|
||||
const isLeaf = node.nodeName === '#text' || node.nodeName === 'P';
|
||||
if (isLeaf) {
|
||||
const childRanges = [];
|
||||
for (const term of searchRegexes) {
|
||||
const matches = node.textContent.matchAll(term);
|
||||
|
||||
for (const match of matches) {
|
||||
const startIndex = match.index ?? 0;
|
||||
const endIndex = (match.index ?? 0) + match[0].length;
|
||||
if (startIndex !== endIndex) {
|
||||
childRanges.push(buildRange(node, startIndex, endIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (childRanges.length > 0) {
|
||||
ranges.set(node, childRanges);
|
||||
}
|
||||
} else {
|
||||
for (const child of node.childNodes) {
|
||||
processNode(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closestParagraph = (node: Node) => node.parentElement?.closest('p');
|
||||
|
||||
const onNodeChanged = (node: Node) => {
|
||||
highlight?.clear();
|
||||
|
||||
processNode(closestParagraph(node) ?? node);
|
||||
|
||||
highlight = new editorWindow.Highlight(...[...ranges.values()].flat());
|
||||
editorWindow.CSS.highlights.set(searchHighlightKey, highlight);
|
||||
};
|
||||
|
||||
const onNodeRemoved = (node: Node, parent: Node) => {
|
||||
const removeFromRanges = (node: Node) => {
|
||||
ranges.delete(node);
|
||||
for (const child of node.childNodes) {
|
||||
removeFromRanges(child);
|
||||
}
|
||||
};
|
||||
removeFromRanges(node);
|
||||
|
||||
// Handle the case where removing a node created a match:
|
||||
onNodeChanged(closestParagraph(parent) ?? parent);
|
||||
};
|
||||
|
||||
const clearHighlights = () => {
|
||||
highlight?.clear();
|
||||
editorWindow.CSS.highlights.delete(searchHighlightKey);
|
||||
ranges.clear();
|
||||
};
|
||||
|
||||
return { onNodeChanged, onNodeRemoved, clearHighlights, canHighlight: searchRegexes.length > 0 };
|
||||
}, [editor, searchRegexes]);
|
||||
};
|
||||
|
||||
const useHighlightedSearchTerms = (editor: Editor, searchTerms: HighlightedWord[], themeId: number) => {
|
||||
const searchRegexes = useSearchRegexes(searchTerms);
|
||||
useHighlightStyleSheet(editor, themeId);
|
||||
const highlighter = useHighlighter(editor, searchRegexes);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !highlighter.canHighlight) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const editorBody = editor.getBody();
|
||||
|
||||
type NodeChangeEvent = { element: Element };
|
||||
const onNodeChange = ({ element }: EditorEvent<NodeChangeEvent>) => {
|
||||
highlighter.onNodeChanged(element);
|
||||
};
|
||||
|
||||
const onKeyUp = (_event: KeyboardEvent) => {
|
||||
// Use selectedNode and not event.target -- event.target seems to always point
|
||||
// to the body.
|
||||
const selectedNode = editor.selection.getNode();
|
||||
if (selectedNode) {
|
||||
highlighter.onNodeChanged(selectedNode);
|
||||
}
|
||||
};
|
||||
|
||||
const onSetContent = () => {
|
||||
highlighter.onNodeChanged(editorBody);
|
||||
};
|
||||
|
||||
editor.on('NodeChange', onNodeChange);
|
||||
editor.on('SetContent', onSetContent);
|
||||
|
||||
// NodeChange doesn't fire while typing, so we also need keyup
|
||||
editor.on('keyup', onKeyUp);
|
||||
|
||||
// NodeChange also doesn't fire for certain DOM changes (e.g. when TinyMCE-provided search highlights are added/removed):
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.removedNodes) {
|
||||
highlighter.onNodeRemoved(node, mutation.target);
|
||||
}
|
||||
|
||||
for (const node of mutation.addedNodes) {
|
||||
highlighter.onNodeChanged(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(editorBody, { subtree: true, childList: true });
|
||||
|
||||
onSetContent();
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
highlighter.clearHighlights();
|
||||
|
||||
editor.off('NodeChange', onNodeChange);
|
||||
editor.off('keyup', onKeyUp);
|
||||
editor.off('SetContent', onSetContent);
|
||||
};
|
||||
}, [editor, highlighter]);
|
||||
};
|
||||
|
||||
export default useHighlightedSearchTerms;
|
||||
@@ -6,7 +6,9 @@ import shim from '@joplin/lib/shim';
|
||||
const useLinkTooltips = (editor: Editor|null) => {
|
||||
const resetModifiedTitles = useCallback(() => {
|
||||
if (!editor) return;
|
||||
for (const element of editor.getDoc().querySelectorAll('a[data-joplin-original-title]')) {
|
||||
const doc = editor.getDoc();
|
||||
if (!doc) return;
|
||||
for (const element of doc.querySelectorAll('a[data-joplin-original-title]')) {
|
||||
element.setAttribute('title', element.getAttribute('data-joplin-original-title') ?? '');
|
||||
element.removeAttribute('data-joplin-original-title');
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { WhiteboardNodeData } from '../canvasFlow';
|
||||
import useCheckboxToggle from '../useCheckboxToggle';
|
||||
import handlePositions from './handlePositions';
|
||||
import useCardWheel from './useCardWheel';
|
||||
import stripUnsafeStyles from './stripUnsafeStyles';
|
||||
|
||||
const logger = Logger.create('WhiteboardFileNode');
|
||||
|
||||
@@ -124,7 +125,7 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
|
||||
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, resolved.body, {
|
||||
resourceInfos: linkedResources,
|
||||
});
|
||||
if (!cancelled) setNoteHtml(result?.html ?? '');
|
||||
if (!cancelled) setNoteHtml(stripUnsafeStyles(result?.html ?? ''));
|
||||
} catch {
|
||||
if (!cancelled) setNoteHtml('');
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { WhiteboardNodeData } from '../canvasFlow';
|
||||
import useCheckboxToggle from '../useCheckboxToggle';
|
||||
import handlePositions from './handlePositions';
|
||||
import useCardWheel from './useCardWheel';
|
||||
import stripUnsafeStyles from './stripUnsafeStyles';
|
||||
|
||||
const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
|
||||
const [html, setHtml] = useState<string>('');
|
||||
@@ -26,7 +27,7 @@ const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
|
||||
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, md, {
|
||||
resourceInfos: ctx.resourceInfos,
|
||||
});
|
||||
if (!cancelled) setHtml(result?.html ?? '');
|
||||
if (!cancelled) setHtml(stripUnsafeStyles(result?.html ?? ''));
|
||||
} catch {
|
||||
if (!cancelled) setHtml('');
|
||||
}
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import stripUnsafeStyles from './stripUnsafeStyles';
|
||||
|
||||
describe('stripUnsafeStyles', () => {
|
||||
|
||||
test.each([
|
||||
[
|
||||
'removes a style element',
|
||||
'<p>Hello</p><style>.rli-sideBar { outline: 3px solid magenta; }</style>',
|
||||
'<p>Hello</p>',
|
||||
],
|
||||
[
|
||||
'removes a style element containing a remote @import',
|
||||
'<style>@import url(http://evil.example/beacon.css);</style><p>Hi</p>',
|
||||
'<p>Hi</p>',
|
||||
],
|
||||
[
|
||||
'removes multiple style elements',
|
||||
'<style>a{}</style><p>x</p><style>b{}</style>',
|
||||
'<p>x</p>',
|
||||
],
|
||||
[
|
||||
'removes an uppercase STYLE element',
|
||||
'<P>x</P><STYLE>@import url(http://evil.example/x.css);</STYLE>',
|
||||
'<p>x</p>',
|
||||
],
|
||||
[
|
||||
'removes a mixed-case Style element',
|
||||
'<Style>a{}</Style><p>y</p>',
|
||||
'<p>y</p>',
|
||||
],
|
||||
[
|
||||
'keeps rich card content (images, formatting, links)',
|
||||
'<p><strong>Bold</strong> <a href="https://example.com">link</a> <img src="x.png"></p>',
|
||||
'<p><strong>Bold</strong> <a href="https://example.com">link</a> <img src="x.png"></p>',
|
||||
],
|
||||
[
|
||||
'keeps inline style attributes, which cannot reach the app chrome',
|
||||
'<p style="color: red">text</p>',
|
||||
'<p style="color: red">text</p>',
|
||||
],
|
||||
])('should %s', (_label, input, expected) => {
|
||||
expect(stripUnsafeStyles(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test('should return empty input unchanged', () => {
|
||||
expect(stripUnsafeStyles('')).toBe('');
|
||||
});
|
||||
});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// Whiteboard cards render into the main document, not the isolated note-viewer
|
||||
// iframe, so a note's <style> would reach the app chrome. Removing <style>
|
||||
// elements also drops any @import; inline style="" attributes are kept as they
|
||||
// only affect the card itself.
|
||||
const stripUnsafeStyles = (html: string) => {
|
||||
if (!html || !/<style/i.test(html)) return html;
|
||||
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
for (const style of Array.from(doc.querySelectorAll('style'))) {
|
||||
style.remove();
|
||||
}
|
||||
return doc.body.innerHTML;
|
||||
};
|
||||
|
||||
export default stripUnsafeStyles;
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { flipNthCheckbox } from './useCheckboxToggle';
|
||||
// `flipNthCheckbox` must count exactly the same subset so the Nth rendered
|
||||
// checkbox maps to the Nth source-text checkbox.
|
||||
|
||||
describe('flipNthCheckbox', () => {
|
||||
describe('useCheckboxToggle', () => {
|
||||
test('flips an unchecked box to checked', () => {
|
||||
expect(flipNthCheckbox('- [ ] todo', 0)).toBe('- [x] todo');
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import styles_ from './styles';
|
||||
import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEditorRef, NoteBodyEditorPropsAndRef, NoteBodyEditorType } from './utils/types';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
import eventManager, { EventName, NoteLockNoteStateChangeEvent } 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,6 +28,10 @@ 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';
|
||||
@@ -62,6 +66,7 @@ import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
|
||||
import getResourceBaseUrl from './utils/getResourceBaseUrl';
|
||||
import useInitialCursorLocation from './utils/useInitialCursorLocation';
|
||||
import NotePositionService, { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
|
||||
import { blur } from '@joplin/lib/utils/focusHandler';
|
||||
|
||||
const debounce = require('debounce');
|
||||
|
||||
@@ -80,6 +85,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
|
||||
const [isReadOnly, setIsReadOnly] = useState<boolean>(false);
|
||||
const [reloadInProgress, setReloadInProgress] = useState(false);
|
||||
|
||||
const editorRef = useRef<NoteBodyEditorRef|null>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement|null>(null);
|
||||
@@ -92,11 +98,20 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
}, []);
|
||||
|
||||
const setFormNoteRef = useRef<OnSetFormNote>(null);
|
||||
const formNoteRef = useRef<FormNote>(null);
|
||||
const { saveNoteIfWillChange, scheduleSaveNote } = useScheduleSaveCallbacks({
|
||||
setFormNote: setFormNoteRef, dispatch: props.dispatch, editorRef, editorId,
|
||||
setFormNote: setFormNoteRef, formNote: formNoteRef, dispatch: props.dispatch, editorRef, editorId,
|
||||
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
|
||||
});
|
||||
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]);
|
||||
|
||||
@@ -107,8 +122,37 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const effectiveNoteId = useEffectiveNoteId(props);
|
||||
const { editorPlugin, editorView } = usePluginEditorView(props.plugins);
|
||||
const builtInEditorVisible = !editorPlugin;
|
||||
const windowId = useContext(WindowIdContext);
|
||||
const onDecryptFailedChange = useCallback((value: boolean) => {
|
||||
props.dispatch({ type: 'SET_ACTIVE_NOTE_IS_UNDECRYPTABLE', value, windowId });
|
||||
}, [props.dispatch, windowId]);
|
||||
const onReloadInProgressChange = useCallback((value: boolean) => {
|
||||
if (value) {
|
||||
// TinyMCE edits inside its own document. Blurring only the outer window does
|
||||
// not reliably stop input when the editor is in a secondary window.
|
||||
editorRef.current?.blurEditor?.();
|
||||
|
||||
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
|
||||
// Blur only the window whose editor is being refreshed. A secondary window
|
||||
// can refresh in response to a change saved by the main editor; blurring the
|
||||
// global document here would incorrectly interrupt typing in the main window.
|
||||
const editorWindowActiveElement = containerRef.current?.ownerDocument?.activeElement;
|
||||
if (editorWindowActiveElement) {
|
||||
blur('NoteEditor::reloadEditorWindow', editorWindowActiveElement);
|
||||
}
|
||||
}
|
||||
setReloadInProgress(value);
|
||||
if (!value) {
|
||||
// TinyMCE can report onWillChange before a reload, then have its delayed
|
||||
// onChange rejected while the reload is in progress. Since no save is queued
|
||||
// in that case, clear the saving status when the reload completes.
|
||||
props.dispatch({
|
||||
type: 'EDITOR_NOTE_STATUS_REMOVE',
|
||||
id: effectiveNoteId,
|
||||
});
|
||||
}
|
||||
}, [effectiveNoteId, props.dispatch]);
|
||||
|
||||
const { formNote, setFormNote, isNewNote, resourceInfos, decryptFailed, loadBlocked } = useFormNote({
|
||||
noteId: effectiveNoteId,
|
||||
isProvisional: props.isProvisional,
|
||||
titleInputRef: titleInputRef,
|
||||
@@ -117,14 +161,48 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
onAfterLoad: formNote_afterLoad,
|
||||
builtInEditorVisible,
|
||||
editorId,
|
||||
noteLockSessionUnlocked: props.noteLockSessionUnlocked,
|
||||
onDecryptFailedChange,
|
||||
onReloadInProgressChange,
|
||||
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
|
||||
});
|
||||
setFormNoteRef.current = setFormNote;
|
||||
const formNoteRef = useRef<FormNote>(formNote);
|
||||
formNoteRef.current = { ...formNote };
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNoteLockEnabled()) return () => {};
|
||||
// Pending scheduled saves read the lock state from the form note, so it must follow an
|
||||
// enable/disable triggered outside the editor (note list menu, another window) immediately.
|
||||
const onLockStateChange = (event: NoteLockNoteStateChangeEvent) => {
|
||||
if (formNoteRef.current.id !== event.noteId) return;
|
||||
// Enabling requires an unlocked session, so capture the key the same way a decrypt
|
||||
// does - a pending save can then still encrypt if the session locks before it runs.
|
||||
const noteLockKey = event.isLocked && NoteLockSession.instance().isUnlocked() ? NoteLockSession.instance().decryptedKey() : null;
|
||||
const newFormNote = { ...formNoteRef.current, is_locked: event.isLocked ? 1 : 0, noteLockKey, isDecrypted: event.isLocked };
|
||||
setFormNote(newFormNote);
|
||||
void scheduleSaveNote(newFormNote);
|
||||
};
|
||||
eventManager.on(EventName.NoteLockNoteStateChange, onLockStateChange);
|
||||
return () => {
|
||||
eventManager.off(EventName.NoteLockNoteStateChange, onLockStateChange);
|
||||
};
|
||||
}, [setFormNote, scheduleSaveNote]);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!isNoteLockEnabled() || props.noteLockSessionUnlocked) return;
|
||||
const lockedFormNote = formNoteRef.current;
|
||||
if (!lockedFormNote?.is_locked || !lockedFormNote.hasChanged) return;
|
||||
// The panel replaces the editor only once the edits are written, so flush them the same way
|
||||
// a note switch does before locking.
|
||||
await saveNoteIfWillChange(lockedFormNote);
|
||||
// The editor dispatches its pending change when it unmounts, which would be saved over the
|
||||
// flushed one - clearing the change id makes onFieldChange drop it, as a note switch does.
|
||||
setFormNote(prev => ({ ...prev, bodyWillChangeId: 0 }));
|
||||
await lockedFormNote.saveActionQueue?.waitForAllDone();
|
||||
}, [props.noteLockSessionUnlocked, saveNoteIfWillChange]);
|
||||
|
||||
const formNoteFolder = useFolder({ folderId: formNote.parent_id });
|
||||
|
||||
const windowId = useContext(WindowIdContext);
|
||||
const shownEditorViewIds = useVisiblePluginEditorViewIds(props.plugins, windowId);
|
||||
useConnectToEditorPlugin({
|
||||
startupPluginsLoaded: props.startupPluginsLoaded,
|
||||
@@ -210,6 +288,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
}, [formNote.title, props.onTitleChange]);
|
||||
|
||||
const onFieldChange = useCallback(async (field: string, value: string, changeId = 0) => {
|
||||
if (reloadInProgress) return;
|
||||
if (!isMountedRef.current) {
|
||||
// When the component is unmounted, various actions can happen which can
|
||||
// trigger onChange events, for example the textarea might be cleared.
|
||||
@@ -258,7 +337,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
// - debounced because many calls to scheduleSaveNote can resolve at once
|
||||
scheduleNoteListResort();
|
||||
}
|
||||
}, [handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
|
||||
}, [reloadInProgress, handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
|
||||
|
||||
const onDrop = useDropHandler({ editorRef });
|
||||
|
||||
@@ -417,7 +496,16 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
return <div style={emptyDivStyle} ref={containerRef}></div>;
|
||||
}
|
||||
|
||||
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
|
||||
const searchMarkers = useSearchMarkers({
|
||||
showLocalSearch,
|
||||
localSearchMarkerOptions,
|
||||
noteId: props.noteId,
|
||||
searchResults: props.searchResults,
|
||||
searchId: props.selectedSearchId,
|
||||
searches: props.searches,
|
||||
highlightedWords: props.highlightedWords,
|
||||
noteTitle: formNote.title,
|
||||
});
|
||||
const initialCursorLocation = useInitialCursorLocation({
|
||||
noteId: props.noteId,
|
||||
});
|
||||
@@ -434,13 +522,14 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
content: formNote.body,
|
||||
contentMarkupLanguage: markupLanguage,
|
||||
contentOriginalCss: formNote.originalCss,
|
||||
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
|
||||
initialCursorLocation,
|
||||
resourceInfos: resourceInfos,
|
||||
resourceDirectory: Setting.value('resourceDir'),
|
||||
htmlToMarkdown: htmlToMarkdown,
|
||||
markupToHtml: markupToHtml,
|
||||
allAssets: allAssets,
|
||||
disabled: isReadOnly,
|
||||
disabled: isReadOnly || reloadInProgress,
|
||||
themeId: props.themeId,
|
||||
dispatch: props.dispatch,
|
||||
noteToolbar: null,
|
||||
@@ -469,7 +558,9 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
useCustomPdfViewer: props.useCustomPdfViewer,
|
||||
// We need it to identify the context for which media is rendered.
|
||||
// It is currently used to remember pdf scroll position for each attachments of each note uniquely.
|
||||
noteId: props.noteId,
|
||||
// Use the form note object to ensure the editor is associated with the loaded form note until onBeforeLoad
|
||||
// has flushed any pending changes from it.
|
||||
noteId: formNote.id,
|
||||
watchedNoteFiles: props.watchedNoteFiles,
|
||||
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
|
||||
showNoteLinkIcon: props.showNoteLinkIcon,
|
||||
@@ -492,7 +583,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
}, [noteHasWhiteboardFence, props.dispatch]);
|
||||
|
||||
if (useWhiteboardEditor) {
|
||||
editor = <WhiteboardEditor {...editorProps}/>;
|
||||
editor = <WhiteboardEditor key={formNote.id} {...editorProps}/>;
|
||||
} else if (builtInEditorVisible) {
|
||||
if (props.bodyEditor === 'TinyMCE') {
|
||||
editor = <TinyMCE {...editorProps}/>;
|
||||
@@ -660,7 +751,43 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
/>;
|
||||
};
|
||||
|
||||
if (formNote.encryption_applied || !formNote.id || !effectiveNoteId) {
|
||||
// A locked note has no form note while the session is locked (see loadNoteForForm), so the
|
||||
// panel is driven by the note metadata. A loaded form note stays mounted on lock only if it
|
||||
// has unsaved changes, so they are not thrown away.
|
||||
const lockedNoteMetadata = isNoteLockEnabled() && effectiveNoteId ? props.notes.find(n => n.id === effectiveNoteId) : null;
|
||||
if (lockedNoteMetadata?.is_locked) {
|
||||
// The session is unlocked but the note content failed to decrypt (e.g. it was encrypted
|
||||
// prior to a password reset) - locking the session again shows the regular unlock panel.
|
||||
if (decryptFailed && props.noteLockSessionUnlocked) {
|
||||
return (
|
||||
<div style={styles.root} ref={containerRef}>
|
||||
<NoteLockPanel
|
||||
noteTitle={lockedNoteMetadata.title}
|
||||
hasNoteLockKey={props.hasNoteLockKey}
|
||||
dispatch={props.dispatch}
|
||||
undecryptable={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const formNoteLoaded = formNote.id === effectiveNoteId;
|
||||
const showLockPanel = formNoteLoaded
|
||||
? !props.noteLockSessionUnlocked && !formNote.hasChanged
|
||||
: !props.noteLockSessionUnlocked || loadBlocked;
|
||||
if (showLockPanel) {
|
||||
return (
|
||||
<div style={styles.root} ref={containerRef}>
|
||||
<NoteLockPanel
|
||||
noteTitle={lockedNoteMetadata.title}
|
||||
hasNoteLockKey={props.hasNoteLockKey}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((!editorPlugin && reloadInProgress) || formNote.encryption_applied || !formNote.id || !effectiveNoteId) {
|
||||
return renderNoNotes(styles.root);
|
||||
}
|
||||
|
||||
@@ -672,6 +799,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const note = props.notes.find(n => n.id === props.selectedNoteIds[0]);
|
||||
if (!note) return null;
|
||||
if (note.markup_language !== MarkupLanguage.Html) return null;
|
||||
if (isNoteLockEnabled() && note.is_locked && !props.noteLockSessionUnlocked) return null;
|
||||
|
||||
return (
|
||||
<div style={styles.resourceWatchBanner}>
|
||||
@@ -700,7 +828,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
noteTitle={formNote.title}
|
||||
noteUserUpdatedTime={formNote.user_updated_time}
|
||||
onTitleChange={onTitleChange}
|
||||
disabled={isReadOnly}
|
||||
disabled={isReadOnly || reloadInProgress}
|
||||
/>
|
||||
{renderSearchInfo()}
|
||||
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%', minHeight: '0' }}>
|
||||
@@ -725,6 +853,15 @@ 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);
|
||||
@@ -784,6 +921,9 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
enableInEditorRendering: state.settings['editor.inlineRendering'],
|
||||
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
|
||||
whiteboardForceMarkdown: windowState.whiteboardForceMarkdown ?? {},
|
||||
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
|
||||
hasNoteLockKey: hasNoteLockKey(state.settings['syncInfoCache']),
|
||||
editorNoteReloadTimeRequest: windowState.windowEditorNoteReloadTimeRequest,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.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,7 +2,6 @@ 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> {
|
||||
@@ -26,7 +25,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
|
||||
return newBody;
|
||||
}
|
||||
|
||||
export async function formNoteToNote(formNote: FormNote): Promise<NoteEntity> {
|
||||
export async function formNoteToNote(formNote: FormNote) {
|
||||
return {
|
||||
id: formNote.id,
|
||||
// Should also include parent_id and deleted_time so that the reducer
|
||||
@@ -37,5 +36,7 @@ export async function formNoteToNote(formNote: FormNote): Promise<NoteEntity> {
|
||||
is_conflict: formNote.is_conflict,
|
||||
title: formNote.title,
|
||||
body: formNote.body,
|
||||
is_locked: formNote.is_locked,
|
||||
isDecrypted: formNote.isDecrypted,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { RefObject, SetStateAction } from 'react';
|
||||
import * as React from 'react';
|
||||
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
|
||||
import { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
|
||||
import type { DecryptedNoteLockKey } from '@joplin/lib/services/noteLock/NoteLockKey';
|
||||
import { HighlightedWord, SearchEntry } from '@joplin/lib/reducer';
|
||||
|
||||
export interface AllAssetsOptions {
|
||||
contentMaxWidthTarget?: string;
|
||||
@@ -47,13 +49,12 @@ export interface NoteEditorProps {
|
||||
notesParentType: string;
|
||||
selectedNoteTags: TagEntity[];
|
||||
selectedNoteHash: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- searches: any[] is the shape in lib's reducer; tightening requires updating the reducer first
|
||||
searches: any[];
|
||||
searches: SearchEntry[];
|
||||
selectedSearchId: string;
|
||||
customCss: string;
|
||||
noteVisiblePanes: string[];
|
||||
watchedResources: Record<string, unknown>;
|
||||
highlightedWords: string[];
|
||||
highlightedWords: HighlightedWord[];
|
||||
tabMovesFocus: boolean;
|
||||
plugins: PluginStates;
|
||||
toolbarButtonInfos: ToolbarItem[];
|
||||
@@ -73,10 +74,14 @@ export interface NoteEditorProps {
|
||||
enableHtmlToMarkdownBanner: boolean;
|
||||
showNoteLinkIcon: boolean;
|
||||
whiteboardForceMarkdown: Record<string, boolean>;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
hasNoteLockKey: boolean;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorRef {
|
||||
content(): string|Promise<string>;
|
||||
blurEditor?(): void;
|
||||
resetScroll(): void;
|
||||
scrollTo(options: ScrollOptions): void;
|
||||
|
||||
@@ -113,6 +118,7 @@ export interface NoteBodyEditorProps {
|
||||
contentKey: string;
|
||||
contentMarkupLanguage: number;
|
||||
contentOriginalCss: string;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
initialCursorLocation: EditorCursorLocations;
|
||||
onChange(event: OnChangeEvent): void;
|
||||
onWillChange(event: { changeId: number }): void;
|
||||
@@ -168,6 +174,12 @@ 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;
|
||||
@@ -220,6 +232,9 @@ export function defaultFormNote(): FormNote {
|
||||
hasChanged: false,
|
||||
user_updated_time: 0,
|
||||
encryption_applied: 0,
|
||||
is_locked: 0,
|
||||
noteLockKey: null,
|
||||
isDecrypted: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import useFormNote, { HookDependencies } from './useFormNote';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import { join } from 'path';
|
||||
import { formNoteToNote } from '.';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
|
||||
const defaultFormNoteProps: HookDependencies = {
|
||||
noteId: '',
|
||||
@@ -16,6 +20,16 @@ const defaultFormNoteProps: HookDependencies = {
|
||||
onAfterLoad: () => { },
|
||||
editorId: 'editor',
|
||||
builtInEditorVisible: false,
|
||||
noteLockSessionUnlocked: false,
|
||||
onDecryptFailedChange: () => {},
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve: (value: T)=> void;
|
||||
const promise = new Promise<T>(resolvePromise => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve: resolve! };
|
||||
};
|
||||
|
||||
describe('useFormNote', () => {
|
||||
@@ -24,6 +38,99 @@ describe('useFormNote', () => {
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
// The session and decryption internals are covered by the lib tests; here they are mocked to
|
||||
// test only the hook's gating: ciphertext must never reach the form note.
|
||||
it('should not produce a form note for a locked note while the session is locked, and decrypt it once unlocked', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
// A direct save of a new note with is_locked keeps the raw body, standing in for ciphertext.
|
||||
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
|
||||
|
||||
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(false);
|
||||
const decryptedKeyMock = jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
|
||||
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => ({ ...note, body: 'secret content' }));
|
||||
const onBeforeLoad = jest.fn();
|
||||
const onDecryptFailedChange = jest.fn();
|
||||
|
||||
try {
|
||||
const lockedRender = renderHook(props => useFormNote(props), {
|
||||
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange },
|
||||
});
|
||||
// The blocked load produces no state change to wait for; onBeforeLoad is its last
|
||||
// await, so once it has run a single flush completes the branch.
|
||||
await waitFor(() => expect(onBeforeLoad).toHaveBeenCalled());
|
||||
await act(async () => {});
|
||||
expect(lockedRender.result.current.formNote).toMatchObject({
|
||||
id: '',
|
||||
body: '',
|
||||
noteLockKey: null,
|
||||
});
|
||||
expect(lockedRender.result.current.loadBlocked).toBe(true);
|
||||
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(false);
|
||||
|
||||
isUnlockedMock.mockReturnValue(true);
|
||||
lockedRender.rerender({ ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange, noteLockSessionUnlocked: true });
|
||||
await waitFor(() => {
|
||||
expect(lockedRender.result.current.formNote).toMatchObject({
|
||||
id: testNote.id,
|
||||
is_locked: 1,
|
||||
body: 'secret content',
|
||||
noteLockKey: { id: 'key-id', plainText: 'key' },
|
||||
});
|
||||
});
|
||||
expect(lockedRender.result.current.loadBlocked).toBe(false);
|
||||
lockedRender.unmount();
|
||||
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
isUnlockedMock.mockReturnValue(false);
|
||||
const flagOffRender = renderHook(props => useFormNote(props), {
|
||||
initialProps: { ...defaultFormNoteProps, noteId: testNote.id },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(flagOffRender.result.current.formNote.id).toBe(testNote.id);
|
||||
});
|
||||
expect(flagOffRender.result.current.formNote).toMatchObject({
|
||||
body: 'ciphertext',
|
||||
});
|
||||
flagOffRender.unmount();
|
||||
} finally {
|
||||
isUnlockedMock.mockRestore();
|
||||
decryptedKeyMock.mockRestore();
|
||||
decryptBodyMock.mockRestore();
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should report a decryption failure instead of throwing, without producing a form note', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
|
||||
// The save's own change event would otherwise reach the hook's listener and reload the note.
|
||||
await ItemChange.waitForAllSaved();
|
||||
|
||||
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
|
||||
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockRejectedValue(new Error('OperationError'));
|
||||
const onDecryptFailedChange = jest.fn();
|
||||
|
||||
try {
|
||||
const render = renderHook(props => useFormNote(props), {
|
||||
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, noteLockSessionUnlocked: true, onDecryptFailedChange },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(render.result.current.decryptFailed).toBe(true);
|
||||
});
|
||||
expect(render.result.current.formNote).toMatchObject({
|
||||
id: '',
|
||||
body: '',
|
||||
});
|
||||
expect(render.result.current.loadBlocked).toBe(false);
|
||||
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(true);
|
||||
render.unmount();
|
||||
} finally {
|
||||
isUnlockedMock.mockRestore();
|
||||
decryptBodyMock.mockRestore();
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should update note when decryption completes', async () => {
|
||||
const testNote = await Note.save({ title: 'Test Note!' });
|
||||
|
||||
@@ -101,7 +208,7 @@ describe('useFormNote', () => {
|
||||
is_conflict: 1,
|
||||
title: testNote.title,
|
||||
});
|
||||
});
|
||||
}, { timeout: 15_000 });
|
||||
|
||||
// Should preserve is_conflict after save.
|
||||
expect(await formNoteToNote(formNote.result.current.formNote)).toMatchObject({
|
||||
@@ -115,10 +222,12 @@ describe('useFormNote', () => {
|
||||
|
||||
it('should reload the note when it is changed outside of the editor', async () => {
|
||||
const note = await Note.save({ title: 'Test Note!', body: '...' });
|
||||
const onReloadInProgressChange = jest.fn();
|
||||
|
||||
const props = {
|
||||
...defaultFormNoteProps,
|
||||
noteId: note.id,
|
||||
onReloadInProgressChange,
|
||||
};
|
||||
|
||||
const formNote = renderHook(props => useFormNote(props), {
|
||||
@@ -137,10 +246,59 @@ describe('useFormNote', () => {
|
||||
await waitFor(() => {
|
||||
expect(formNote.result.current.formNote.title).toBe('Modified');
|
||||
});
|
||||
expect(onReloadInProgressChange).not.toHaveBeenCalled();
|
||||
|
||||
formNote.unmount();
|
||||
});
|
||||
|
||||
it('should force a reload when editorNoteReloadTimeRequest changes', async () => {
|
||||
const note = await Note.save({ title: 'Original', body: '...' });
|
||||
const onReloadInProgressChange = jest.fn();
|
||||
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
|
||||
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
|
||||
|
||||
act(() => {
|
||||
formNote.result.current.setFormNote(previous => ({ ...previous, title: 'Unsaved local title', hasChanged: true }));
|
||||
});
|
||||
await Note.save({ id: note.id, title: 'Remote title' }, { changeId: 'test-editor' });
|
||||
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
|
||||
|
||||
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Remote title'));
|
||||
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
|
||||
formNote.unmount();
|
||||
});
|
||||
|
||||
it('should clear reloadInProgress after overlapping reload requests', async () => {
|
||||
const note = await Note.save({ title: 'Original', body: '...' });
|
||||
const onReloadInProgressChange = jest.fn();
|
||||
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
|
||||
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
|
||||
|
||||
const firstLoad = deferred<typeof note>();
|
||||
const lastLoad = deferred<typeof note>();
|
||||
const loadMock = jest.spyOn(Note, 'load')
|
||||
.mockImplementationOnce(() => firstLoad.promise)
|
||||
.mockImplementationOnce(() => lastLoad.promise);
|
||||
|
||||
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
|
||||
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(1));
|
||||
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 2 });
|
||||
|
||||
firstLoad.resolve({ ...note, title: 'First reload' });
|
||||
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(2));
|
||||
expect(onReloadInProgressChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
lastLoad.resolve({ ...note, title: 'Last reload' });
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Last reload'));
|
||||
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
|
||||
|
||||
loadMock.mockRestore();
|
||||
formNote.unmount();
|
||||
});
|
||||
|
||||
test('should refresh resource infos when changed outside the editor', async () => {
|
||||
let note = await Note.save({});
|
||||
note = await shim.attachFileToNote(note, join(supportDir, 'sample.txt'));
|
||||
|
||||
@@ -9,12 +9,15 @@ 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');
|
||||
|
||||
@@ -31,6 +34,10 @@ export interface HookDependencies {
|
||||
onBeforeLoad(event: OnLoadEvent): void;
|
||||
onAfterLoad(event: OnLoadEvent): void;
|
||||
builtInEditorVisible: boolean;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
onDecryptFailedChange(value: boolean): void;
|
||||
onReloadInProgressChange?(value: boolean): void;
|
||||
editorNoteReloadTimeRequest?: number;
|
||||
}
|
||||
|
||||
type MapFormNoteCallback = (previousFormNote: FormNote)=> FormNote;
|
||||
@@ -66,16 +73,41 @@ function resourceInfosChanged(a: ResourceInfos, b: ResourceInfos): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean)=> Promise<FormNote>;
|
||||
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId: string, noteId: string, initNoteState: InitNoteStateCallback, builtInEditorVisible: boolean) => {
|
||||
// While the session is locked, a locked note produces no form note at all - the unlock panel
|
||||
// takes the editor's place. So no placeholder body exists that a save could write over the note.
|
||||
const loadNoteForForm = async (noteId: string): Promise<{ note: NoteEntity|null; blocked: boolean; decryptFailed: boolean }> => {
|
||||
if (isNoteLockEnabled()) {
|
||||
const lockState = await Note.load(noteId, { fields: ['is_locked'] });
|
||||
if (lockState && NoteLockNote.isLocked(lockState)) {
|
||||
if (!NoteLockSession.instance().isUnlocked()) return { note: null, blocked: true, decryptFailed: false };
|
||||
try {
|
||||
return { note: await Note.load(noteId, { useNoteLock: true }), blocked: false, decryptFailed: false };
|
||||
} catch (error) {
|
||||
// Happens when the note was encrypted with a different key, e.g. before a password
|
||||
// reset. Fail closed and let the editor show the cannot-decrypt panel.
|
||||
logger.warn('Could not decrypt locked note:', noteId, error);
|
||||
return { note: null, blocked: false, decryptFailed: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { note: await Note.load(noteId), blocked: false, decryptFailed: false };
|
||||
};
|
||||
|
||||
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean, force?: boolean)=> Promise<FormNote>;
|
||||
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId: string, noteId: string, initNoteState: InitNoteStateCallback, clearFormNote: ()=> void, builtInEditorVisible: boolean, noteLockSessionUnlocked: boolean, setDecryptFailed: (value: boolean)=> void, setLoadBlocked: (value: boolean)=> void, onReloadInProgressChange: (value: boolean)=> void, editorNoteReloadTimeRequest: number) => {
|
||||
// Increasing the value of this counter cancels any ongoing note refreshes and starts
|
||||
// a new refresh.
|
||||
const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);
|
||||
const [formNoteRefreshRequest, setFormNoteRefreshRequest] = useState({ counter: 0, force: false });
|
||||
const previousEditorNoteReloadTimeRequestRef = useRef(editorNoteReloadTimeRequest);
|
||||
const prevBuiltInEditorVisible = usePrevious<boolean>(builtInEditorVisible);
|
||||
// Seeded because usePrevious defaults to null, which would read as a session change on mount.
|
||||
const prevNoteLockSessionUnlocked = usePrevious<boolean>(noteLockSessionUnlocked, noteLockSessionUnlocked);
|
||||
|
||||
useQueuedAsyncEffect(async (event) => {
|
||||
if (formNoteRefreshScheduled <= 0) return;
|
||||
if (formNoteRef.current.hasChanged) {
|
||||
if (event.cancelled) return;
|
||||
if (formNoteRefreshRequest.counter <= 0) return;
|
||||
const forceRefresh = formNoteRefreshRequest.force;
|
||||
if (formNoteRef.current.hasChanged && !forceRefresh) {
|
||||
logger.info('Form note changed between scheduling a refresh and the refresh itself. Cancelling the refresh.');
|
||||
return;
|
||||
}
|
||||
@@ -83,38 +115,58 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
|
||||
logger.info('Sync has finished and note has never been changed - reloading it');
|
||||
|
||||
const loadNote = async () => {
|
||||
const n = await Note.load(noteId);
|
||||
if (event.cancelled || formNoteRef.current.hasChanged) return;
|
||||
setDecryptFailed(false);
|
||||
const { note: n, blocked, decryptFailed } = await loadNoteForForm(noteId);
|
||||
if (event.cancelled || (formNoteRef.current.hasChanged && !forceRefresh)) 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;
|
||||
setLoadBlocked(blocked);
|
||||
setDecryptFailed(decryptFailed);
|
||||
if (blocked || decryptFailed) {
|
||||
// The session locked with this note open (or its content stopped decrypting) - drop
|
||||
// the decrypted content and let the corresponding panel take over.
|
||||
if (formNoteRef.current.id) clearFormNote();
|
||||
} else {
|
||||
// Normally should not happened because if the note has been deleted via sync
|
||||
// it would not have been loaded in the editor (due to note selection changing
|
||||
// on delete)
|
||||
if (!n) {
|
||||
logger.warn('Trying to reload note that has been deleted:', noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
await initNoteState(n, false, forceRefresh);
|
||||
if (event.cancelled) return;
|
||||
}
|
||||
|
||||
await initNoteState(n, false);
|
||||
if (event.cancelled) return;
|
||||
setFormNoteRefreshScheduled(oldValue => {
|
||||
setFormNoteRefreshRequest(oldValue => {
|
||||
// If a new refresh was scheduled between initNoteState
|
||||
// and now:
|
||||
if (oldValue !== formNoteRefreshScheduled) {
|
||||
if (oldValue.counter !== formNoteRefreshRequest.counter) {
|
||||
return oldValue;
|
||||
}
|
||||
// A refresh is no longer scheduled
|
||||
return 0;
|
||||
return { counter: 0, force: false };
|
||||
});
|
||||
};
|
||||
|
||||
await loadNote();
|
||||
}, [formNoteRefreshScheduled, noteId, editorId, initNoteState]);
|
||||
try {
|
||||
await loadNote();
|
||||
} finally {
|
||||
if (forceRefresh && !event.cancelled) onReloadInProgressChange(false);
|
||||
}
|
||||
}, [formNoteRefreshRequest, noteId, editorId, initNoteState, clearFormNote, setDecryptFailed, onReloadInProgressChange]);
|
||||
|
||||
const refreshFormNote = useCallback(() => {
|
||||
const refreshFormNote = useCallback((force = false) => {
|
||||
// Increase the counter to cancel any ongoing refresh attempts
|
||||
// and start a new one.
|
||||
setFormNoteRefreshScheduled(count => count + 1);
|
||||
}, []);
|
||||
if (force) onReloadInProgressChange(true);
|
||||
setFormNoteRefreshRequest(previous => ({ counter: previous.counter + 1, force: previous.force || force }));
|
||||
}, [onReloadInProgressChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorNoteReloadTimeRequest === previousEditorNoteReloadTimeRequestRef.current) return;
|
||||
previousEditorNoteReloadTimeRequestRef.current = editorNoteReloadTimeRequest;
|
||||
refreshFormNote(true);
|
||||
}, [editorNoteReloadTimeRequest, refreshFormNote]);
|
||||
|
||||
// When switching from the plugin editor to the built-in editor, we refresh the note since the
|
||||
// plugin may have modified it via the data API.
|
||||
@@ -124,6 +176,14 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
|
||||
}
|
||||
}, [builtInEditorVisible, prevBuiltInEditorVisible, refreshFormNote]);
|
||||
|
||||
// Unlocking loads the note the panel was blocking (the form is then empty), locking must drop
|
||||
// a locked note's plaintext - reload to recompute.
|
||||
useEffect(() => {
|
||||
if (isNoteLockEnabled() && prevNoteLockSessionUnlocked !== noteLockSessionUnlocked && (formNoteRef.current.is_locked || formNoteRef.current.id !== noteId)) {
|
||||
refreshFormNote();
|
||||
}
|
||||
}, [noteLockSessionUnlocked, prevNoteLockSessionUnlocked, noteId, formNoteRef, refreshFormNote]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId) return ()=>{};
|
||||
@@ -153,18 +213,38 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
|
||||
|
||||
export default function useFormNote(dependencies: HookDependencies) {
|
||||
const {
|
||||
noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, builtInEditorVisible, editorId,
|
||||
noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, builtInEditorVisible, editorId, noteLockSessionUnlocked, onDecryptFailedChange,
|
||||
} = dependencies;
|
||||
const onReloadInProgressChange = dependencies.onReloadInProgressChange ?? (() => {});
|
||||
const editorNoteReloadTimeRequest = dependencies.editorNoteReloadTimeRequest ?? 0;
|
||||
|
||||
const [formNote, setFormNote] = useState<FormNote>(defaultFormNote());
|
||||
const [isNewNote, setIsNewNote] = useState(false);
|
||||
// Keyed by note id so a failure can never carry over to another note the user switches to.
|
||||
const [decryptFailedId, setDecryptFailedId] = useState<string|null>(null);
|
||||
const setDecryptFailed = useCallback((value: boolean) => {
|
||||
setDecryptFailedId(value ? noteId : null);
|
||||
onDecryptFailedChange(value);
|
||||
}, [noteId, onDecryptFailedChange]);
|
||||
const [loadBlockedId, setLoadBlockedId] = useState<string|null>(null);
|
||||
const setLoadBlocked = useCallback((value: boolean) => setLoadBlockedId(value ? noteId : null), [noteId]);
|
||||
const previousNoteId = usePrevious(formNote.id);
|
||||
const [resourceInfos, setResourceInfos] = useState<ResourceInfos>({});
|
||||
|
||||
const formNoteRef = useRef(formNote);
|
||||
formNoteRef.current = formNote;
|
||||
|
||||
const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote) => {
|
||||
const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote, force = false) => {
|
||||
let noteLockKey = null;
|
||||
if (isNoteLockEnabled() && NoteLockNote.isLocked(n)) {
|
||||
// The session can lock between the gated load and here (e.g. lock-on-switch) - drop
|
||||
// the load rather than show plaintext that pending saves could no longer encrypt.
|
||||
if (!NoteLockSession.instance().isUnlocked()) return null;
|
||||
// Capture the key with the plaintext so pending saves can re-encrypt even after
|
||||
// the session locks.
|
||||
noteLockKey = NoteLockSession.instance().decryptedKey();
|
||||
}
|
||||
|
||||
let originalCss = '';
|
||||
|
||||
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
|
||||
@@ -188,6 +268,9 @@ 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');
|
||||
@@ -200,7 +283,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
|
||||
// If the user changes the note while resources are loading, this can lead to
|
||||
// a note being incorrectly marked as "unchanged".
|
||||
if (!isNewNote && formNoteRef.current?.hasChanged) {
|
||||
if (!isNewNote && formNoteRef.current?.hasChanged && !force) {
|
||||
logger.info('Cancelled note refresh -- form note changed while loading attached resources.');
|
||||
return null;
|
||||
}
|
||||
@@ -215,10 +298,16 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
return newFormNote;
|
||||
}, []);
|
||||
|
||||
useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, builtInEditorVisible);
|
||||
const clearFormNote = useCallback(() => {
|
||||
formNoteRef.current = defaultFormNote();
|
||||
setFormNote(formNoteRef.current);
|
||||
}, []);
|
||||
|
||||
useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, clearFormNote, builtInEditorVisible, noteLockSessionUnlocked, setDecryptFailed, setLoadBlocked, onReloadInProgressChange, editorNoteReloadTimeRequest);
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId) {
|
||||
setDecryptFailed(false);
|
||||
if (formNote.id) setFormNote(defaultFormNote());
|
||||
return () => {};
|
||||
}
|
||||
@@ -244,8 +333,21 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
}
|
||||
|
||||
async function loadNote() {
|
||||
const n = await Note.load(noteId);
|
||||
setDecryptFailed(false);
|
||||
const { note: n, blocked, decryptFailed } = await loadNoteForForm(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);
|
||||
|
||||
@@ -327,5 +429,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
formNote,
|
||||
setFormNote: onSetFormNote,
|
||||
resourceInfos,
|
||||
decryptFailed: !!noteId && decryptFailedId === noteId,
|
||||
loadBlocked: !!noteId && loadBlockedId === noteId,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { RefObject, useCallback } from 'react';
|
||||
import { RefObject, useCallback, useRef } from 'react';
|
||||
import { FormNote, NoteBodyEditorRef } from './types';
|
||||
import { formNoteToNote } from '.';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
@@ -7,27 +7,44 @@ import Note from '@joplin/lib/models/Note';
|
||||
import type { Dispatch } from 'redux';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
import type { OnSetFormNote } from './useFormNote';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
|
||||
const logger = Logger.create('useScheduleSaveCallbacks');
|
||||
|
||||
interface Props {
|
||||
setFormNote: RefObject<OnSetFormNote>;
|
||||
formNote: RefObject<FormNote>;
|
||||
editorId: string;
|
||||
dispatch: Dispatch;
|
||||
editorRef: RefObject<NoteBodyEditorRef>;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
}
|
||||
|
||||
const useScheduleSaveCallbacks = (props: Props) => {
|
||||
const editorNoteReloadTimeRequestRef = useRef(props.editorNoteReloadTimeRequest);
|
||||
editorNoteReloadTimeRequestRef.current = props.editorNoteReloadTimeRequest;
|
||||
|
||||
const scheduleSaveNote = useCallback((formNote: FormNote) => {
|
||||
if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check
|
||||
|
||||
// reg.logger().debug('Scheduling...', formNote);
|
||||
|
||||
const editorNoteReloadTimeRequest = editorNoteReloadTimeRequestRef.current;
|
||||
const makeAction = (formNote: FormNote) => {
|
||||
return async function() {
|
||||
const note = await formNoteToNote(formNote);
|
||||
logger.debug('Saving note...', note);
|
||||
const savedNote = await Note.save(note, { changeId: `editorChange-${props.editorId}` });
|
||||
if (editorNoteReloadTimeRequestRef.current > editorNoteReloadTimeRequest) return;
|
||||
|
||||
// The lock state may change between scheduling and execution (e.g. encryption enabled
|
||||
// from the note list menu), so the save uses the latest form state for this note.
|
||||
const latestFormNote = props.formNote.current?.id === formNote.id ? props.formNote.current : formNote;
|
||||
let note;
|
||||
if (isNoteLockEnabled()) {
|
||||
note = await formNoteToNote({ ...formNote, is_locked: latestFormNote.is_locked, isDecrypted: latestFormNote.isDecrypted });
|
||||
} else {
|
||||
note = await formNoteToNote(formNote);
|
||||
}
|
||||
logger.debug('Saving note...', isNoteLockEnabled() && note.is_locked ? note.id : note);
|
||||
const savedNote = await Note.save(note, { changeId: `editorChange-${props.editorId}`, useNoteLock: true, noteLockKey: latestFormNote.noteLockKey });
|
||||
|
||||
props.setFormNote.current((prev: FormNote) => {
|
||||
return { ...prev, user_updated_time: savedNote.user_updated_time, hasChanged: false };
|
||||
@@ -46,7 +63,7 @@ const useScheduleSaveCallbacks = (props: Props) => {
|
||||
|
||||
formNote.saveActionQueue.push(makeAction(formNote));
|
||||
return formNote.saveActionQueue.waitForAllDone();
|
||||
}, [props.dispatch, props.editorId, props.setFormNote]);
|
||||
}, [props.dispatch, props.editorId, props.setFormNote, props.formNote]);
|
||||
|
||||
const saveNoteIfWillChange = useCallback(async (formNote: FormNote) => {
|
||||
if (!formNote.id || !formNote.bodyWillChangeId || !props.editorRef.current) return;
|
||||
@@ -59,6 +76,7 @@ const useScheduleSaveCallbacks = (props: Props) => {
|
||||
bodyWillChangeId: 0,
|
||||
bodyChangeId: 0,
|
||||
});
|
||||
await formNote.saveActionQueue.processAllNow();
|
||||
}, [scheduleSaveNote, props.editorRef]);
|
||||
|
||||
return { saveNoteIfWillChange, scheduleSaveNote };
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { runWithFakeTimers, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import useSearchMarkers from './useSearchMarkers';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import { setUpSemanticSearch, tearDownSemanticSearch, updateSearchIndex } from '@joplin/lib/testing/ai/semanticSearch';
|
||||
|
||||
|
||||
describe('useSearchMarkers', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setUpSemanticSearch();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await tearDownSemanticSearch();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'should return markers for semantic search results',
|
||||
noteBody: 'example example example example',
|
||||
searchQuery: 'examples example example example',
|
||||
expected: [{ type: 'text', value: 'example example example example' }],
|
||||
},
|
||||
{
|
||||
label: 'should exclude Markdown from keywords',
|
||||
noteBody: '*test-test-test-test*! test',
|
||||
// cSpell:disable
|
||||
searchQuery: 'testt-test-test-test',
|
||||
// cSpell:enable
|
||||
expected: [{ type: 'text', value: 'test-test-test-test' }],
|
||||
},
|
||||
])('should return keywords for semantic search matches: $label', async ({ noteBody, searchQuery, expected }) => {
|
||||
const note = await Note.save({ title: 'test', body: noteBody });
|
||||
await updateSearchIndex();
|
||||
|
||||
await runWithFakeTimers(async () => {
|
||||
const searchResults = await SearchEngine.instance().search(searchQuery);
|
||||
const test = renderHook(() => useSearchMarkers({
|
||||
showLocalSearch: false,
|
||||
localSearchMarkerOptions: () => null,
|
||||
noteId: note.id,
|
||||
searchResults,
|
||||
searchId: '1',
|
||||
searches: [{ id: '1', query_pattern: searchQuery, query_type: 1, type_: -1, title: searchQuery }],
|
||||
highlightedWords: [],
|
||||
noteTitle: note.title,
|
||||
}));
|
||||
|
||||
await act(() => jest.advanceTimersByTimeAsync(5 * Second));
|
||||
|
||||
await waitFor(() => {
|
||||
const result = test.result.current;
|
||||
expect(result.keywords).toMatchObject(expected);
|
||||
}, { timeout: 30 * Second });
|
||||
test.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
|
||||
import { HighlightedWord, SearchEntry } from '@joplin/lib/reducer';
|
||||
import SearchService, { SearchResult } from '@joplin/lib/services/ai/SearchService';
|
||||
import { ProcessResultsRow, SearchType } from '@joplin/lib/services/search/SearchEngine';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
interface SearchMarkersOptions {
|
||||
searchTimestamp: number;
|
||||
@@ -7,8 +12,9 @@ interface SearchMarkersOptions {
|
||||
withSelection?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export interface SearchMarkers {
|
||||
keywords: { value: string; type?: string; accuracy?: string }[];
|
||||
keywords: HighlightedWord[];
|
||||
options: SearchMarkersOptions;
|
||||
}
|
||||
|
||||
@@ -23,16 +29,116 @@ function defaultSearchMarkers(): SearchMarkers {
|
||||
};
|
||||
}
|
||||
|
||||
// The indexer can prepend the note title to the first chunk:
|
||||
// Remove it so that the results are exact substrings of the note body
|
||||
const removeNoteTitle = (result: SearchResult, noteTitle: string) => {
|
||||
if (result.chunkIndex !== 0) return result;
|
||||
|
||||
let chunkText = result.chunkText.trim();
|
||||
while (chunkText.startsWith(`${noteTitle}\n`)) {
|
||||
chunkText = chunkText.substring(noteTitle.length).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
chunkText,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseSemanticSearchMatchesProps {
|
||||
query: string;
|
||||
noteId: string;
|
||||
noteTitle: string;
|
||||
}
|
||||
|
||||
// Returns matching substrings of the note for the given search query
|
||||
const useSemanticSearchMatches = ({ query, noteId, noteTitle }: UseSemanticSearchMatchesProps) => {
|
||||
const [matchingChunks, setMatchingChunks] = useState<string[]>([]);
|
||||
const matchingChunksRef = useRef(matchingChunks);
|
||||
matchingChunksRef.current = matchingChunks;
|
||||
|
||||
useEffect(() => {
|
||||
if (!query && matchingChunksRef.current.length !== 0) {
|
||||
setMatchingChunks([]);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const noteTitleRef = useRef(noteTitle);
|
||||
noteTitleRef.current = noteTitle;
|
||||
|
||||
useQueuedAsyncEffect(async (event) => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await SearchService.instance().search({
|
||||
query: { text: query }, scope: { type: 'note', noteId }, relevance: 'strict',
|
||||
});
|
||||
if (event.cancelled) return;
|
||||
|
||||
const matches = [];
|
||||
for (const result of results) {
|
||||
const bestMatch = await SearchService.instance().bestMatchInResult(
|
||||
query, removeNoteTitle(result, noteTitleRef.current),
|
||||
);
|
||||
if (event.cancelled) return;
|
||||
matches.push(bestMatch);
|
||||
}
|
||||
|
||||
setMatchingChunks(matches);
|
||||
}, [query, noteId], { interval: Second });
|
||||
|
||||
return matchingChunks;
|
||||
};
|
||||
|
||||
interface UseSearchMarkersProps {
|
||||
showLocalSearch: boolean;
|
||||
localSearchMarkerOptions: ()=> SearchMarkers;
|
||||
noteId: string;
|
||||
searchResults: ProcessResultsRow[];
|
||||
searchId: string;
|
||||
searches: SearchEntry[];
|
||||
highlightedWords: HighlightedWord[];
|
||||
noteTitle: string;
|
||||
}
|
||||
|
||||
export default function useSearchMarkers({
|
||||
showLocalSearch,
|
||||
localSearchMarkerOptions,
|
||||
noteId,
|
||||
searchResults,
|
||||
searchId,
|
||||
searches,
|
||||
highlightedWords,
|
||||
noteTitle,
|
||||
}: UseSearchMarkersProps) {
|
||||
const currentNoteSearchResult = useMemo(() => {
|
||||
return searchResults.find(result => result.id === noteId);
|
||||
}, [searchResults, noteId]);
|
||||
|
||||
const semanticSearchQuery = useMemo(() => {
|
||||
if (!currentNoteSearchResult) return null;
|
||||
if (!currentNoteSearchResult.searchType.includes(SearchType.Semantic)) return null;
|
||||
const search = searches.find(search => search.id === searchId);
|
||||
return search.query_pattern;
|
||||
}, [searches, searchId, currentNoteSearchResult]);
|
||||
|
||||
const semanticSearchMatches = useSemanticSearchMatches({
|
||||
query: semanticSearchQuery, noteId, noteTitle,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-explicit-any -- Function type matches the original signature; any[] matches the lib reducer's searches shape and the heterogeneous highlightedWords shape (string[] at the call site, keyword shapes inside)
|
||||
export default function useSearchMarkers(showLocalSearch: boolean, localSearchMarkerOptions: Function, searches: any[], selectedSearchId: string, highlightedWords: any[] = []) {
|
||||
return useMemo((): SearchMarkers => {
|
||||
if (showLocalSearch) return localSearchMarkerOptions();
|
||||
|
||||
const output = defaultSearchMarkers();
|
||||
output.keywords = highlightedWords;
|
||||
output.keywords = highlightedWords ?? [];
|
||||
|
||||
if (semanticSearchMatches.length) {
|
||||
output.keywords = output.keywords.concat(semanticSearchMatches.map(match => (
|
||||
{ type: 'text', accuracy: 'partial', value: match, source: 'semantic' }
|
||||
)));
|
||||
}
|
||||
|
||||
return output;
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, searches, selectedSearchId]);
|
||||
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, semanticSearchMatches]);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ type Props = {
|
||||
makeItemIndexVisible: (index: number)=> void;
|
||||
};
|
||||
|
||||
describe('useAutoScroll', () => {
|
||||
describe('UseAutoScroll', () => {
|
||||
|
||||
test('scrolls to the note when a new note is selected', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { Dispatch } from 'redux';
|
||||
import { EventEmitter } from 'events';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
export interface Props {
|
||||
themeId: number;
|
||||
@@ -27,7 +28,7 @@ export interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- searches: any[] is the shape in lib's reducer; tightening requires updating the reducer first
|
||||
searches: any[];
|
||||
selectedSearchId: string;
|
||||
highlightedWords: string[];
|
||||
highlightedWords: HighlightedWord[];
|
||||
provisionalNoteIds: string[];
|
||||
visible: boolean;
|
||||
focusedField: string;
|
||||
|
||||
@@ -7,14 +7,20 @@ import useItemElement from './utils/useItemElement';
|
||||
import { ItemEventHandlers, OnInputChange } from './utils/types';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import bridge from '../../services/bridge';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useRenderedNote from './utils/useRenderedNote';
|
||||
import { Dispatch } from 'redux';
|
||||
import getNoteElementIdFromJoplinId from './utils/getNoteElementIdFromJoplinId';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
interface NoteItemProps {
|
||||
dragIndex: number;
|
||||
flow: ItemFlow;
|
||||
highlightedWords: string[];
|
||||
highlightedWords: HighlightedWord[];
|
||||
index: number;
|
||||
isProvisional: boolean;
|
||||
itemSize: Size;
|
||||
@@ -57,6 +63,15 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
};
|
||||
|
||||
if (changeEvent.elementId === 'todo-checkbox') {
|
||||
if (isNoteLockEnabled()) {
|
||||
const lockState = await Note.load(changeEvent.noteId, { fields: ['is_locked'] });
|
||||
if (NoteLockNote.isLocked(lockState) && !NoteLockSession.instance().isUnlocked()) {
|
||||
// event.currentTarget is already cleared here, after the await - target is the same input
|
||||
event.target.checked = !changeEvent.value;
|
||||
bridge().showErrorMessageBox(_('Cannot change a locked note while the session is locked'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
await Note.save({
|
||||
id: changeEvent.noteId,
|
||||
todo_completed: changeEvent.value ? Date.now() : 0,
|
||||
|
||||
@@ -2,8 +2,9 @@ import { htmlentities } from '@joplin/utils/html';
|
||||
const Mark = require('mark.js/dist/mark.min.js');
|
||||
const markJsUtils = require('@joplin/lib/markJsUtils');
|
||||
import { replaceRegexDiacritics, pregQuote } from '@joplin/lib/string-utils';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
const getNoteTitleHtml = (highlightedWords: string[], displayTitle: string) => {
|
||||
const getNoteTitleHtml = (highlightedWords: HighlightedWord[], displayTitle: string) => {
|
||||
if (highlightedWords.length) {
|
||||
const titleElement = document.createElement('span');
|
||||
titleElement.textContent = displayTitle;
|
||||
|
||||
@@ -11,6 +11,7 @@ import prepareViewProps from './prepareViewProps';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import { unique } from '@joplin/lib/array';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
interface RenderedNote {
|
||||
id: string;
|
||||
@@ -22,7 +23,7 @@ const hashContent = (content: unknown) => {
|
||||
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
|
||||
};
|
||||
|
||||
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: string[], itemIndex: number, columns: NoteListColumns) => {
|
||||
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: HighlightedWord[], itemIndex: number, columns: NoteListColumns) => {
|
||||
const [renderedNote, setRenderedNote] = useState<RenderedNote>(null);
|
||||
|
||||
let dependencies = columns && columns.length ? columns.map(c => c.name) as ListRendererDependency[] : [];
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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,6 +35,7 @@ export interface NoteViewerControl {
|
||||
focusLine(editorLine: number): void;
|
||||
focus(): void;
|
||||
hasFocus(): boolean;
|
||||
stopMediaPlayback(): void;
|
||||
}
|
||||
|
||||
const usePluginMessageResponder = (webviewRef: RefObject<HTMLIFrameElement>) => {
|
||||
@@ -143,6 +144,10 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
|
||||
if (channel === 'setMarkers') {
|
||||
win.postMessage({ target: 'webview', name: 'setMarkers', data: { keywords: arg0, options: arg1 } }, '*');
|
||||
}
|
||||
|
||||
if (channel === 'stopMediaPlayback') {
|
||||
win.postMessage({ target: 'webview', name: 'stopMediaPlayback', data: {} }, '*');
|
||||
}
|
||||
},
|
||||
focus: () => {
|
||||
if (webviewRef.current) {
|
||||
@@ -168,6 +173,9 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
stopMediaPlayback: () => {
|
||||
result.send('stopMediaPlayback');
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}, [parentDoc]);
|
||||
|
||||
@@ -35,7 +35,7 @@ const PasswordInput = (props: Props) => {
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<button onClick={onShowPassword} className="showpasswordbutton">
|
||||
<button type="button" onClick={onShowPassword} className="showpasswordbutton">
|
||||
<i className={icon} role='img' aria-label={title} title={title}></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
> .showpasswordbutton {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import PromptDialog from './PromptDialog';
|
||||
|
||||
describe('PromptDialog Escape key handling', () => {
|
||||
describe('PromptDialog', () => {
|
||||
|
||||
const setupKeyHandler = (inputType: string, menuIsOpened: boolean) => {
|
||||
const onCloseMock = jest.fn();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import DialogButtonRow, { ClickEvent } from './DialogButtonRow';
|
||||
import Dialog from '@joplin/lib/components/Dialog';
|
||||
import DialogTitle from './DialogTitle';
|
||||
@@ -15,6 +17,7 @@ import { clipboard } from 'electron';
|
||||
import useOnPublishFolderLinkClick from '@joplin/lib/components/shared/PublishFolderDialog/useOnPublishFolderLinkClick';
|
||||
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
|
||||
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
|
||||
import Button from './Button/Button';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -28,6 +31,7 @@ export function PublishFolderDialog(props: Props) {
|
||||
const [folder, setFolder] = useState<FolderEntity>(null);
|
||||
const [noteCount, setNoteCount] = useState<number>(0);
|
||||
const [publishFolderStatus, setPublishFolderStatus] = useState<SharingStatus>(SharingStatus.Unknown);
|
||||
const [isUnpublishing, setIsUnpublishing] = useState(false);
|
||||
|
||||
const encryptionWarning = useEncryptionWarningMessage();
|
||||
const publishedShare = useMemo(() => {
|
||||
@@ -68,19 +72,37 @@ export function PublishFolderDialog(props: Props) {
|
||||
props.onClose();
|
||||
}, [props]);
|
||||
|
||||
const onUnpublishFolderClick = useCallback(async () => {
|
||||
setIsUnpublishing(true);
|
||||
try {
|
||||
await ShareService.instance().unpublishFolder(props.folderId);
|
||||
} catch (error) {
|
||||
void shim.showErrorDialog(JoplinServerApi.connectionErrorMessage(error as Error));
|
||||
} finally {
|
||||
setIsUnpublishing(false);
|
||||
}
|
||||
}, [props.folderId]);
|
||||
|
||||
const statusMessage = useShareStatusMessage({ sharesState: publishFolderStatus, noteCount: 1 });
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<div className="form share-note-dialog">
|
||||
<DialogTitle title={_('Publish Notebook')}/>
|
||||
<div className="folder-info">
|
||||
<p>{_('Notebook: %s', folder ? folder.title : '...')}</p>
|
||||
<p>{_('Status: %s', publishedShare ? _('Published') : _('Not published'))}</p>
|
||||
<p>{_n('%d note in notebook', '%d notes in notebook', noteCount, noteCount)}</p>
|
||||
<div className="shared-note-list-item">
|
||||
<div className="title">
|
||||
<div className="folder-info">
|
||||
<p>{_('Notebook: %s', folder ? folder.title : '...')}</p>
|
||||
<p>{_('Status: %s', publishedShare ? _('Published') : _('Not published'))}</p>
|
||||
<p>{_n('%d note in notebook', '%d notes in notebook', noteCount, noteCount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{publishedShare && (
|
||||
<Button disabled={isUnpublishing} tooltip={_('Unpublish notebook')} iconName="fas fa-share-alt" onClick={onUnpublishFolderClick}/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
disabled={!folder || [SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(publishFolderStatus) >= 0}
|
||||
disabled={isUnpublishing || !folder || [SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(publishFolderStatus) >= 0}
|
||||
className="share"
|
||||
onClick={publishFolderLinkButton_click}
|
||||
>{publishedShare ? _('Copy Shareable Link') : _('Publish notebook')}</button>
|
||||
|
||||
@@ -34,43 +34,35 @@ const LayoutItemContainer: React.FC<Props> = ({
|
||||
const canResizeBottom = parentDir === LayoutItemDirection.Column && !isLastChild;
|
||||
|
||||
const className = `resizableLayoutItem rli-${item.key}`;
|
||||
if (canResizeRight || canResizeBottom) {
|
||||
const enable = {
|
||||
top: false,
|
||||
right: canResizeRight,
|
||||
bottom: canResizeBottom,
|
||||
left: false,
|
||||
topRight: false,
|
||||
bottomRight: false,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
};
|
||||
const enable = {
|
||||
top: false,
|
||||
right: canResizeRight,
|
||||
bottom: canResizeBottom,
|
||||
left: false,
|
||||
topRight: false,
|
||||
bottomRight: false,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
key={item.key}
|
||||
className={className}
|
||||
style={style}
|
||||
size={size}
|
||||
onResizeStart={onResizeStart}
|
||||
onResize={onResize}
|
||||
onResizeStop={onResizeStop}
|
||||
enable={enable}
|
||||
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
|
||||
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
|
||||
maxWidth={resizedItemMaxSize?.width}
|
||||
maxHeight={resizedItemMaxSize?.height}
|
||||
>
|
||||
{children}
|
||||
</Resizable>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={item.key} className={className} style={{ ...style, ...size }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Resizable
|
||||
key={item.key}
|
||||
className={className}
|
||||
style={style}
|
||||
size={size}
|
||||
onResizeStart={onResizeStart}
|
||||
onResize={onResize}
|
||||
onResizeStop={onResizeStop}
|
||||
enable={enable}
|
||||
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
|
||||
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
|
||||
maxWidth={resizedItemMaxSize?.width}
|
||||
maxHeight={resizedItemMaxSize?.height}
|
||||
>
|
||||
{children}
|
||||
</Resizable>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutItemContainer;
|
||||
@@ -33,6 +33,7 @@ import SsoLoginScreen from './SsoLoginScreen/SsoLoginScreen';
|
||||
import SamlShared from '@joplin/lib/components/shared/SamlShared';
|
||||
import PopupNotificationProvider from './PopupNotification/PopupNotificationProvider';
|
||||
import { ThemeProvider, StyleSheetManager, createGlobalStyle } from 'styled-components';
|
||||
import QuitSyncDialog from './QuitSyncDialog';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -178,6 +179,7 @@ class RootComponent extends React.Component<Props, any> {
|
||||
<MenuBar/>
|
||||
<GlobalStyle/>
|
||||
<WindowCommandsAndDialogs windowId={defaultWindowId} />
|
||||
<QuitSyncDialog themeId={this.props.themeId} />
|
||||
<Navigator style={navigatorStyle} screens={screens} className={`profile-${this.props.profileConfigCurrentProfileId}`} />
|
||||
{this.renderSecondaryWindows()}
|
||||
{this.renderModalMessage(this.modalDialogProps())}
|
||||
|
||||
@@ -170,7 +170,6 @@ function ShareFolderDialog(props: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
setRecipientSuggestions([]);
|
||||
setLatestError(null);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -245,7 +244,9 @@ function ShareFolderDialog(props: Props) {
|
||||
ShareService.instance().refreshShares(),
|
||||
ShareService.instance().refreshShareUsers(share.id),
|
||||
]);
|
||||
setRecipientEmail('');
|
||||
// Only clear the input on success, otherwise the user loses the email
|
||||
// they typed and the error message would be wiped by the suggestions effect.
|
||||
if (!errorSet) setRecipientEmail('');
|
||||
|
||||
await synchronize();
|
||||
} catch (error) {
|
||||
@@ -257,6 +258,7 @@ function ShareFolderDialog(props: Props) {
|
||||
|
||||
const recipientEmail_change = useCallback((value: string) => {
|
||||
setRecipientEmail(value);
|
||||
setLatestError(null);
|
||||
}, []);
|
||||
|
||||
const renderRecipientSuggestion = useCallback((suggestedValue: string) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import onUnshareNoteClick from '@joplin/lib/components/shared/ShareNoteDialog/on
|
||||
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
|
||||
import useEncryptionWarningMessage from '@joplin/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage';
|
||||
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
|
||||
import useIsPublished from '@joplin/lib/components/shared/ShareNoteDialog/useIsPublished';
|
||||
import { clipboard } from 'electron';
|
||||
|
||||
interface Props {
|
||||
@@ -66,22 +67,10 @@ export function ShareNoteDialog(props: Props) {
|
||||
recursiveShare,
|
||||
});
|
||||
|
||||
const renderNote = (note: NoteEntity) => {
|
||||
const unshareButton = !props.shares.find(s => s.note_id === note.id) ? null : (
|
||||
<Button tooltip={_('Unpublish note')} iconName="fas fa-share-alt" onClick={() => onUnshareNoteClick({ noteId: note.id })}/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={note.id} className='shared-note-list-item'>
|
||||
<span className='title'>{note.title}</span>{unshareButton}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNoteList = (notes: NoteEntity[]) => {
|
||||
const noteComps = [];
|
||||
for (const note of notes) {
|
||||
noteComps.push(renderNote(note));
|
||||
noteComps.push(<NoteItem key={note.id} note={note} shares={props.shares}/>);
|
||||
}
|
||||
return <div className="notes">{noteComps}</div>;
|
||||
};
|
||||
@@ -136,6 +125,25 @@ export function ShareNoteDialog(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
interface NoteItemProps {
|
||||
note: NoteEntity;
|
||||
shares: StateShare[];
|
||||
}
|
||||
|
||||
const NoteItem: React.FC<NoteItemProps> = ({ note, shares }) => {
|
||||
const published = useIsPublished(note, shares);
|
||||
|
||||
const unshareButton = published && (
|
||||
<Button tooltip={_('Unpublish note')} iconName='fas fa-share-alt' onClick={() => onUnshareNoteClick({ noteId: note.id })}/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='shared-note-list-item'>
|
||||
<span className='title'>{note.title}</span>{unshareButton}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
shares: state.shareService.shares.filter(s => !!s.note_id),
|
||||
|
||||
@@ -32,13 +32,15 @@ const SidebarComponent = (props: Props) => {
|
||||
const renderSynchronizeButton = (type: string) => {
|
||||
const label = type === 'sync' ? _('Synchronise') : _('Cancel');
|
||||
const nothingToSync = type === 'sync' && !props.syncPending && syncCompletedWithoutError(props.syncReport);
|
||||
const iconName = nothingToSync ? 'fas fa-check' : 'icon-sync';
|
||||
const syncHasErrors = type === 'sync' && !props.syncPending && !!props.syncReport.errors?.length;
|
||||
const iconName = syncHasErrors ? 'fas fa-exclamation-triangle' : nothingToSync ? 'fas fa-check' : 'icon-sync';
|
||||
|
||||
return (
|
||||
<StyledSynchronizeButton
|
||||
level={ButtonLevel.SidebarSecondary}
|
||||
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'} ${nothingToSync ? '-synced' : ''}`}
|
||||
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'} ${nothingToSync ? '-synced' : ''} ${syncHasErrors ? '-error' : ''}`}
|
||||
iconName={iconName}
|
||||
iconLabel={syncHasErrors ? _('Synchronisation error') : undefined}
|
||||
key="sync_button"
|
||||
title={label}
|
||||
onClick={() => {
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
&.-error > .icon {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
&:not(.-syncing):not(.-synced) > .icon {
|
||||
animation: icon-fade-in-b 300ms ease-in-out;
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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',
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
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: '!noteIsReadOnly',
|
||||
enabledCondition: '!noteIsReadOnlyShare',
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
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,10 +2,11 @@
|
||||
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';
|
||||
@@ -13,6 +14,7 @@ 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';
|
||||
@@ -59,10 +61,11 @@ const index: any[] = [
|
||||
addNoteToWhiteboard,
|
||||
addProfile,
|
||||
commandPalette,
|
||||
copyNoteExternalLink,
|
||||
deleteFolder,
|
||||
disableNoteEncryption,
|
||||
duplicateNote,
|
||||
editAlarm,
|
||||
enableNoteEncryption,
|
||||
exportPdf,
|
||||
globalRedo,
|
||||
globalUndo,
|
||||
@@ -70,6 +73,7 @@ const index: any[] = [
|
||||
hideModalMessage,
|
||||
importFrom,
|
||||
linkToNote,
|
||||
lockEncryptedNotes,
|
||||
moveToFolder,
|
||||
newFolder,
|
||||
newNote,
|
||||
|
||||
@@ -34,6 +34,6 @@ export const runtime = (): CommandRuntime => {
|
||||
return result;
|
||||
},
|
||||
|
||||
enabledCondition: 'markdownEditorPaneVisible || richTextEditorVisible',
|
||||
enabledCondition: 'oneNoteSelected && (markdownEditorPaneVisible || richTextEditorVisible) && !noteIsReadOnly && !noteIsDeleted',
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
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 && !noteIsReadOnly',
|
||||
enabledCondition: 'someNotesSelected && !noteIsReadOnlyShare',
|
||||
};
|
||||
};
|
||||
@@ -21,6 +21,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
},
|
||||
enabledCondition: 'someNotesSelected',
|
||||
enabledCondition: 'someNotesSelected && !noteLockContentUnavailable',
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,8 @@ 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 = {
|
||||
@@ -20,6 +22,11 @@ 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) => {
|
||||
@@ -74,6 +81,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
|
||||
},
|
||||
});
|
||||
},
|
||||
enabledCondition: 'someNotesSelected && !inTrash',
|
||||
enabledCondition: 'someNotesSelected && !allSelectedNotesAreDeleted && !inTrash',
|
||||
};
|
||||
};
|
||||
+1
-1
@@ -12,7 +12,7 @@ jest.mock('../../../services/bridge', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('mapStateToTitle', () => {
|
||||
describe('showSpellCheckerMenu', () => {
|
||||
|
||||
test('should return null if spellchecker.enabled is false', () => {
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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',
|
||||
@@ -12,6 +14,11 @@ 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,6 +3,7 @@ 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';
|
||||
|
||||
@@ -32,6 +33,13 @@ 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) => {
|
||||
|
||||
@@ -2,7 +2,6 @@ export default function() {
|
||||
return [
|
||||
'attachFile',
|
||||
'copyDevCommand',
|
||||
'copyNoteExternalLink',
|
||||
'exportPdf',
|
||||
'focusElementNoteBody',
|
||||
'focusElementNoteViewer',
|
||||
@@ -38,6 +37,9 @@ export default function() {
|
||||
'textSelectAll',
|
||||
'textBulletedList',
|
||||
'toggleExternalEditing',
|
||||
'enableNoteEncryption',
|
||||
'disableNoteEncryption',
|
||||
'lockEncryptedNotes',
|
||||
'openNoteInNewWindow',
|
||||
'toggleLayoutMoveMode',
|
||||
'resetLayout',
|
||||
|
||||
@@ -430,6 +430,26 @@
|
||||
}
|
||||
};
|
||||
|
||||
// https://github.com/laurent22/joplin/issues/15278
|
||||
// Embedded players (e.g. YouTube) are cross-origin iframes that can't be
|
||||
// paused from here, so reassigning the src reloads them, which stops playback.
|
||||
function stopMediaPlayback() {
|
||||
for (const element of contentElement.querySelectorAll('video, audio')) {
|
||||
if (!element.paused) element.pause();
|
||||
}
|
||||
|
||||
// Exclude .media-pdf: it's the interactive PDF viewer, not a media
|
||||
// player, and reloading it would reset the current page and scroll.
|
||||
for (const iframe of contentElement.querySelectorAll('iframe:not(.media-pdf)')) {
|
||||
const src = iframe.getAttribute('src');
|
||||
if (src) iframe.setAttribute('src', src);
|
||||
}
|
||||
}
|
||||
|
||||
ipc.stopMediaPlayback = () => {
|
||||
stopMediaPlayback();
|
||||
};
|
||||
|
||||
ipc.setHtml = (event) => {
|
||||
const html = event.html;
|
||||
|
||||
@@ -439,6 +459,8 @@
|
||||
|
||||
alreadyAllImagesLoaded = false;
|
||||
|
||||
stopMediaPlayback();
|
||||
|
||||
contentElement.innerHTML = html;
|
||||
|
||||
if (html.includes('file://')) {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
@@ -20,11 +20,12 @@
|
||||
color: var(--joplin-color);
|
||||
padding: 16px;
|
||||
box-shadow: 6px 6px 20px rgba(0,0,0,0.5);
|
||||
margin: 20px;
|
||||
min-height: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 10px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
@@ -34,6 +35,7 @@
|
||||
&.-fullscreen {
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
padding: 0;
|
||||
|
||||
&::backdrop {
|
||||
background-color: var(--joplin-background-color);
|
||||
|
||||
Loaded 100 of 535 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user