Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87211d1996 | ||
|
|
26f7166200 |
No files matched your search
@@ -1,9 +0,0 @@
|
||||
# Environment variables injected by Yarn into every install/build-script run.
|
||||
# See the injectEnvironmentFiles setting: https://yarnpkg.com/configuration/yarnrc#injectEnvironmentFiles
|
||||
|
||||
# onnxruntime-node bundles the CPU runtime we use, but its postinstall also
|
||||
# downloads the CUDA/GPU execution provider from nuget.org on Linux x64. Joplin
|
||||
# only does CPU inference, so that download is unused - and it randomly times
|
||||
# out and fails the whole `yarn install`. Skip it. This is a no-op on
|
||||
# macOS/Windows, where onnxruntime-node downloads nothing anyway.
|
||||
ONNXRUNTIME_NODE_INSTALL=skip
|
||||
@@ -26,7 +26,6 @@ Valid prefixes:
|
||||
- `Mobile` — the mobile app (both Android and iOS)
|
||||
- `Android` — only the Android app
|
||||
- `iOS` — only the iOS app
|
||||
- `Web` — only the web app
|
||||
- `Windows` — Windows-specific changes
|
||||
- `Linux` — Linux-specific changes
|
||||
- `macOS` — macOS-specific changes
|
||||
|
||||
@@ -25,7 +25,7 @@ module.exports = async ({ github, context, core }) => {
|
||||
// the changelog generator. Non-product prefixes (Tools, Chore, CI, Doc,
|
||||
// Plugin Repo) are administrative categories for changes that do not
|
||||
// ship to users and so do not appear in the changelog.
|
||||
const prefix = '(Desktop|Mobile|Android|iOS|Web|Windows|Linux|macOS|Cli|Clipper|Server|Transcribe|Plugins|Api|Cloud|Tools|Chore|CI|Doc)';
|
||||
const prefix = '(Desktop|Mobile|Android|iOS|Windows|Linux|macOS|Cli|Clipper|Server|Transcribe|Plugins|Api|Cloud|Tools|Chore|CI|Doc)';
|
||||
const prefixList = `${prefix}(,\\s*${prefix})*`;
|
||||
const strictRegex = new RegExp(`^${prefixList}: (Fixes|Resolves) #[0-9]+: .+`);
|
||||
const softRegex = new RegExp(`^${prefixList}: ((Fixes|Resolves) #[0-9]+: )?.+`);
|
||||
|
||||
@@ -45,9 +45,7 @@ jobs:
|
||||
- uses: ./.github/workflows/shared/install-yarn
|
||||
|
||||
- name: Install
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
run: yarn install
|
||||
env:
|
||||
SKIP_ONENOTE_CONVERTER_BUILD: 1
|
||||
|
||||
|
||||
@@ -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-15
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
@@ -53,8 +53,7 @@ jobs:
|
||||
run: |
|
||||
export npm_config_arch=arm64
|
||||
export npm_config_target_arch=arm64
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
yarn install
|
||||
cd packages/app-desktop
|
||||
npm pkg set 'build.mac.artifactName'='${productName}-${version}-${arch}.${ext}'
|
||||
|
||||
|
||||
@@ -178,8 +178,7 @@ jobs:
|
||||
echo "RUNNER_ARCH=$RUNNER_ARCH"
|
||||
echo "DOCKER_IMAGE_PLATFORM=$DOCKER_IMAGE_PLATFORM"
|
||||
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
yarn install
|
||||
yarn buildServerDocker --docker-file Dockerfile.server --platform $DOCKER_IMAGE_PLATFORM --tag-name server-v0.0.0 --repository joplin/server
|
||||
|
||||
# Basic test to ensure that the created build is valid. It should exit with
|
||||
|
||||
@@ -25,9 +25,7 @@ jobs:
|
||||
Install-WindowsDeps
|
||||
- name: Build
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
run: yarn install
|
||||
env:
|
||||
# The onenote-converter package uses Rust, which isn't installed on all CI
|
||||
# runners. Since the onenote-converter doesn't have UI tests, it can be excluded
|
||||
@@ -53,9 +51,7 @@ jobs:
|
||||
- name: Setup build environment
|
||||
uses: ./.github/workflows/shared/setup-build-environment
|
||||
- name: Build
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
run: yarn install
|
||||
env:
|
||||
SKIP_ONENOTE_CONVERTER_BUILD: 1
|
||||
- name: Run mobile UI tests
|
||||
|
||||
@@ -71,8 +71,6 @@ docs/**/*.mustache
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
packages/generator-joplin/generators/app/templates/script/publish/dist/
|
||||
|
||||
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
|
||||
packages/app-cli/app/LinkSelector.js
|
||||
packages/app-cli/app/ResourceServer.js
|
||||
@@ -85,7 +83,6 @@ packages/app-cli/app/cli-utils.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
packages/app-cli/app/command-cat.test.js
|
||||
packages/app-cli/app/command-cat.js
|
||||
packages/app-cli/app/command-clear.js
|
||||
packages/app-cli/app/command-config.js
|
||||
@@ -94,7 +91,6 @@ packages/app-cli/app/command-done.test.js
|
||||
packages/app-cli/app/command-done.js
|
||||
packages/app-cli/app/command-dump.js
|
||||
packages/app-cli/app/command-e2ee.js
|
||||
packages/app-cli/app/command-edit.test.js
|
||||
packages/app-cli/app/command-edit.js
|
||||
packages/app-cli/app/command-exit.js
|
||||
packages/app-cli/app/command-export-sync-status.js
|
||||
@@ -119,7 +115,6 @@ packages/app-cli/app/command-rmnote.test.js
|
||||
packages/app-cli/app/command-rmnote.js
|
||||
packages/app-cli/app/command-search.js
|
||||
packages/app-cli/app/command-server.js
|
||||
packages/app-cli/app/command-set.test.js
|
||||
packages/app-cli/app/command-set.js
|
||||
packages/app-cli/app/command-settingschema.js
|
||||
packages/app-cli/app/command-share.test.js
|
||||
@@ -164,7 +159,6 @@ packages/app-desktop/ElectronAppWrapper.js
|
||||
packages/app-desktop/InteropServiceHelper.js
|
||||
packages/app-desktop/app.reducer.test.js
|
||||
packages/app-desktop/app.reducer.js
|
||||
packages/app-desktop/app.test.js
|
||||
packages/app-desktop/app.js
|
||||
packages/app-desktop/bridge.js
|
||||
packages/app-desktop/checkForUpdates.js
|
||||
@@ -253,8 +247,6 @@ packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
|
||||
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
|
||||
packages/app-desktop/gui/MainLayoutPane.js
|
||||
packages/app-desktop/gui/MainScreen.js
|
||||
packages/app-desktop/gui/MainScreen/handleCallbackUrl.test.js
|
||||
packages/app-desktop/gui/MainScreen/handleCallbackUrl.js
|
||||
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
|
||||
packages/app-desktop/gui/MenuBar.js
|
||||
packages/app-desktop/gui/MultiNoteActions.js
|
||||
@@ -307,7 +299,6 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
|
||||
@@ -324,13 +315,10 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/GroupNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/handlePositions.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
|
||||
packages/app-desktop/gui/NoteEditor/StatusBar.js
|
||||
packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
|
||||
@@ -373,7 +361,6 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useResourceUnwatcher.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
@@ -421,7 +408,6 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
|
||||
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
|
||||
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
|
||||
packages/app-desktop/gui/NotePropertiesDialog.js
|
||||
packages/app-desktop/gui/NoteRevisionViewer.js
|
||||
packages/app-desktop/gui/NoteSearchBar.js
|
||||
@@ -516,11 +502,10 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/copyNoteExternalLink.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/disableNoteEncryption.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalRedo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalUndo.js
|
||||
@@ -529,7 +514,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/lockEncryptedNotes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
|
||||
@@ -549,7 +533,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/resetLayout.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealInNotebook.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.js
|
||||
@@ -604,20 +587,15 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js
|
||||
packages/app-desktop/gui/utils/dragAndDrop.js
|
||||
packages/app-desktop/gui/utils/loadScript.js
|
||||
packages/app-desktop/gulpfile.js
|
||||
packages/app-desktop/integration-tests/chatPanel.spec.js
|
||||
packages/app-desktop/integration-tests/goToAnything.spec.js
|
||||
packages/app-desktop/integration-tests/main.spec.js
|
||||
packages/app-desktop/integration-tests/markdownEditor.spec.js
|
||||
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
|
||||
packages/app-desktop/integration-tests/models/ChatPanel.js
|
||||
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
|
||||
packages/app-desktop/integration-tests/models/GoToAnything.js
|
||||
packages/app-desktop/integration-tests/models/MainScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/BaseEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/EditorCodeDialog.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/MarkdownEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/NoteViewer.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/RichTextEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
@@ -717,7 +695,6 @@ packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
packages/app-mobile/components/BottomDrawer.js
|
||||
packages/app-mobile/components/BottomDrawerMenu.js
|
||||
packages/app-mobile/components/CameraView/ActionButtons.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.jest.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.js
|
||||
@@ -879,8 +856,6 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/expo
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionDescription.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionSelector/SectionTab.js
|
||||
@@ -924,7 +899,6 @@ packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
|
||||
packages/app-mobile/components/screens/LogScreen.js
|
||||
packages/app-mobile/components/screens/Note/Note.test.js
|
||||
packages/app-mobile/components/screens/Note/Note.js
|
||||
packages/app-mobile/components/screens/Note/NoteLockPanel.js
|
||||
packages/app-mobile/components/screens/Note/commands/attachFile.js
|
||||
packages/app-mobile/components/screens/Note/commands/hideKeyboard.js
|
||||
packages/app-mobile/components/screens/Note/commands/index.js
|
||||
@@ -1042,7 +1016,6 @@ packages/app-mobile/utils/database-driver-react-native.web.js
|
||||
packages/app-mobile/utils/debounce.js
|
||||
packages/app-mobile/utils/focusView.js
|
||||
packages/app-mobile/utils/fs-driver/constants.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.test.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.worker.js
|
||||
@@ -1138,6 +1111,8 @@ packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/openLink.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
|
||||
@@ -1168,8 +1143,6 @@ packages/editor/CodeMirror/extensions/rendering/replaceLinks.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceLinks.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeHideReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
|
||||
packages/editor/CodeMirror/extensions/searchExtension.test.js
|
||||
@@ -1188,10 +1161,7 @@ packages/editor/CodeMirror/testing/forceFullParse.js
|
||||
packages/editor/CodeMirror/testing/loadLanguages.js
|
||||
packages/editor/CodeMirror/testing/pressReleaseKey.js
|
||||
packages/editor/CodeMirror/testing/typeText.js
|
||||
packages/editor/CodeMirror/testing/visibleEditorText.js
|
||||
packages/editor/CodeMirror/theme.js
|
||||
packages/editor/CodeMirror/utils/findPositionMatchingLink.test.js
|
||||
packages/editor/CodeMirror/utils/findPositionMatchingLink.js
|
||||
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
|
||||
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.test.js
|
||||
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
|
||||
@@ -1320,6 +1290,9 @@ packages/generator-joplin/generators/app/templates/api/index.js
|
||||
packages/generator-joplin/generators/app/templates/api/noteListType.js
|
||||
packages/generator-joplin/generators/app/templates/api/types.js
|
||||
packages/generator-joplin/generators/app/templates/api_index.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
|
||||
packages/generator-joplin/generators/app/templates/src/index.js
|
||||
packages/generator-joplin/tools/updateCategories.js
|
||||
packages/htmlpack/index.test.js
|
||||
@@ -1337,7 +1310,6 @@ packages/lib/BaseModel.test.js
|
||||
packages/lib/BaseModel.js
|
||||
packages/lib/BaseSyncTarget.js
|
||||
packages/lib/Cache.js
|
||||
packages/lib/ClipperServer.test.js
|
||||
packages/lib/ClipperServer.js
|
||||
packages/lib/CssUtils.js
|
||||
packages/lib/EventDispatcher.test.js
|
||||
@@ -1408,7 +1380,6 @@ packages/lib/components/shared/SamlShared.js
|
||||
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/types.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useIsPublished.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
|
||||
packages/lib/components/shared/SsoScreenShared.js
|
||||
@@ -1423,7 +1394,6 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
|
||||
packages/lib/components/shared/dropbox-login-shared.js
|
||||
packages/lib/components/shared/note-screen-shared.test.js
|
||||
packages/lib/components/shared/note-screen-shared.js
|
||||
packages/lib/components/shared/reduxSharedMiddleware.js
|
||||
packages/lib/components/shared/side-menu-shared.test.js
|
||||
@@ -1499,7 +1469,6 @@ packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/ConflictNoteState.js
|
||||
packages/lib/models/Folder.publishing.test.js
|
||||
packages/lib/models/Folder.sharing.test.js
|
||||
packages/lib/models/Folder.test.js
|
||||
packages/lib/models/Folder.js
|
||||
@@ -1617,13 +1586,13 @@ packages/lib/services/ai/SearchService.js
|
||||
packages/lib/services/ai/SqliteVec.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.js
|
||||
packages/lib/services/ai/applyNoteEdits.js
|
||||
packages/lib/services/ai/availability.test.js
|
||||
packages/lib/services/ai/availability.js
|
||||
packages/lib/services/ai/chunker.test.js
|
||||
packages/lib/services/ai/chunker.js
|
||||
packages/lib/services/ai/classification.js
|
||||
packages/lib/services/ai/noteChat.test.js
|
||||
packages/lib/services/ai/noteChat.tools.test.js
|
||||
packages/lib/services/ai/noteChat.js
|
||||
packages/lib/services/ai/providers/Anthropic.test.js
|
||||
packages/lib/services/ai/providers/Anthropic.js
|
||||
@@ -1634,26 +1603,6 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
|
||||
packages/lib/services/ai/providers/TestProvider.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.js
|
||||
packages/lib/services/ai/tools/ToolIndex.js
|
||||
packages/lib/services/ai/tools/buildEditorTools.js
|
||||
packages/lib/services/ai/tools/global/createNote.js
|
||||
packages/lib/services/ai/tools/global/createNotebook.js
|
||||
packages/lib/services/ai/tools/global/deleteNote.js
|
||||
packages/lib/services/ai/tools/global/index.js
|
||||
packages/lib/services/ai/tools/global/listNotebooks.js
|
||||
packages/lib/services/ai/tools/global/listTags.js
|
||||
packages/lib/services/ai/tools/global/manageTags.js
|
||||
packages/lib/services/ai/tools/global/readImage.js
|
||||
packages/lib/services/ai/tools/global/readNote.js
|
||||
packages/lib/services/ai/tools/global/searchNotes.js
|
||||
packages/lib/services/ai/tools/global/semanticSearchNotes.js
|
||||
packages/lib/services/ai/tools/global/updateNote.js
|
||||
packages/lib/services/ai/tools/types.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.js
|
||||
packages/lib/services/ai/tools/utils/buildTool.js
|
||||
packages/lib/services/ai/tools/utils/serializeToolOutput.js
|
||||
packages/lib/services/ai/tools/utils/settings.js
|
||||
packages/lib/services/ai/types.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
@@ -1665,19 +1614,6 @@ packages/lib/services/commands/isEditorCommand.js
|
||||
packages/lib/services/commands/propsHaveChanged.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.test.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.js
|
||||
packages/lib/services/conflict/autoMergeNote.test.js
|
||||
packages/lib/services/conflict/autoMergeNote.js
|
||||
packages/lib/services/conflict/boundedDiff3.test.js
|
||||
packages/lib/services/conflict/boundedDiff3.js
|
||||
packages/lib/services/conflict/decryptNoteInMemory.js
|
||||
packages/lib/services/conflict/diffNotes.test.js
|
||||
packages/lib/services/conflict/diffNotes.js
|
||||
packages/lib/services/conflict/isAutoMergeEnabled.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
|
||||
@@ -1730,15 +1666,12 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js
|
||||
packages/lib/services/interop/InteropService_Importer_Base.js
|
||||
packages/lib/services/interop/InteropService_Importer_Custom.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToHtml.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.js
|
||||
packages/lib/services/interop/InteropService_Importer_Jex.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
|
||||
packages/lib/services/interop/InteropService_Importer_Obsidian.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Obsidian.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.js
|
||||
@@ -1760,6 +1693,17 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||
packages/lib/services/mcp/McpServer.test.js
|
||||
packages/lib/services/mcp/McpServer.js
|
||||
packages/lib/services/mcp/registry.js
|
||||
packages/lib/services/mcp/tools/createNote.js
|
||||
packages/lib/services/mcp/tools/createNotebook.js
|
||||
packages/lib/services/mcp/tools/deleteNote.js
|
||||
packages/lib/services/mcp/tools/listNotebooks.js
|
||||
packages/lib/services/mcp/tools/listTags.js
|
||||
packages/lib/services/mcp/tools/manageTags.js
|
||||
packages/lib/services/mcp/tools/readNote.js
|
||||
packages/lib/services/mcp/tools/searchNotes.js
|
||||
packages/lib/services/mcp/tools/semanticSearchNotes.js
|
||||
packages/lib/services/mcp/tools/updateNote.js
|
||||
packages/lib/services/mcp/types.js
|
||||
packages/lib/services/noteList/checkboxPieCss.js
|
||||
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
|
||||
@@ -1777,10 +1721,7 @@ packages/lib/services/noteLock/NoteLockService.test.js
|
||||
packages/lib/services/noteLock/NoteLockService.js
|
||||
packages/lib/services/noteLock/NoteLockSession.test.js
|
||||
packages/lib/services/noteLock/NoteLockSession.js
|
||||
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
|
||||
packages/lib/services/noteLock/isNoteLockEnabled.js
|
||||
packages/lib/services/noteLock/setNoteLockState.test.js
|
||||
packages/lib/services/noteLock/setNoteLockState.js
|
||||
packages/lib/services/ocr/OcrDriverBase.js
|
||||
packages/lib/services/ocr/OcrService.test.js
|
||||
packages/lib/services/ocr/OcrService.js
|
||||
@@ -1883,7 +1824,6 @@ packages/lib/services/rest/routes/notes.test.js
|
||||
packages/lib/services/rest/routes/notes.js
|
||||
packages/lib/services/rest/routes/ping.js
|
||||
packages/lib/services/rest/routes/resources.js
|
||||
packages/lib/services/rest/routes/revisions.test.js
|
||||
packages/lib/services/rest/routes/revisions.js
|
||||
packages/lib/services/rest/routes/search.js
|
||||
packages/lib/services/rest/routes/tags.js
|
||||
@@ -1897,7 +1837,6 @@ packages/lib/services/rest/utils/readonlyProperties.js
|
||||
packages/lib/services/rest/utils/requestFields.js
|
||||
packages/lib/services/rest/utils/requestPaginationOptions.js
|
||||
packages/lib/services/search/SearchEngine.resources.test.js
|
||||
packages/lib/services/search/SearchEngine.semantic.test.js
|
||||
packages/lib/services/search/SearchEngine.test.js
|
||||
packages/lib/services/search/SearchEngine.js
|
||||
packages/lib/services/search/SearchEngineUtils.test.js
|
||||
@@ -1927,7 +1866,6 @@ packages/lib/services/synchronizer/ItemUploader.test.js
|
||||
packages/lib/services/synchronizer/ItemUploader.js
|
||||
packages/lib/services/synchronizer/LockHandler.js
|
||||
packages/lib/services/synchronizer/MigrationHandler.js
|
||||
packages/lib/services/synchronizer/Synchronizer.autoMerge.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.basics.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
|
||||
@@ -1979,7 +1917,6 @@ packages/lib/shim-init-node.js
|
||||
packages/lib/shim.js
|
||||
packages/lib/string-utils.test.js
|
||||
packages/lib/string-utils.js
|
||||
packages/lib/testing/ai/semanticSearch.js
|
||||
packages/lib/testing/dom-test-environment.js
|
||||
packages/lib/testing/plugins/createTestPlugin.js
|
||||
packages/lib/testing/share/makeMockShareInvitation.js
|
||||
@@ -2135,7 +2072,6 @@ packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/eslint-rules/describeFilename.test.js
|
||||
packages/tools/fuzzer/ActionRunner.js
|
||||
packages/tools/fuzzer/Fuzzer.js
|
||||
packages/tools/fuzzer/cli.js
|
||||
@@ -2213,8 +2149,8 @@ packages/tools/updateMarkdownDoc.js
|
||||
packages/tools/utils/discourse.test.js
|
||||
packages/tools/utils/discourse.js
|
||||
packages/tools/utils/loadSponsors.js
|
||||
packages/tools/utils/parsePluralLocalizationForm.test.js
|
||||
packages/tools/utils/parsePluralLocalizationForm.js
|
||||
packages/tools/utils/parsePlurallLocalizationForm.test.js
|
||||
packages/tools/utils/translation.js
|
||||
packages/tools/validateFilenames.js
|
||||
packages/tools/website/build.js
|
||||
|
||||
@@ -8,7 +8,6 @@ _releases/
|
||||
**/node_modules/
|
||||
**/abcjs-basic-min.js
|
||||
packages/generator-joplin/generators/app/templates/api/
|
||||
packages/generator-joplin/generators/app/templates/script/publish/dist/
|
||||
Assets/
|
||||
docs/
|
||||
highlight.pack.js
|
||||
@@ -83,7 +82,6 @@ packages/lib/welcomeAssets.js
|
||||
packages/editor/*/vendor/
|
||||
packages/plugins/**/api
|
||||
packages/plugins/**/dist
|
||||
packages/plugin-repo-cli/dist/
|
||||
packages/server/dist/
|
||||
packages/utils/dist/
|
||||
packages/transcribe/dist/
|
||||
@@ -111,7 +109,6 @@ packages/app-cli/app/cli-utils.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
packages/app-cli/app/command-cat.test.js
|
||||
packages/app-cli/app/command-cat.js
|
||||
packages/app-cli/app/command-clear.js
|
||||
packages/app-cli/app/command-config.js
|
||||
@@ -120,7 +117,6 @@ packages/app-cli/app/command-done.test.js
|
||||
packages/app-cli/app/command-done.js
|
||||
packages/app-cli/app/command-dump.js
|
||||
packages/app-cli/app/command-e2ee.js
|
||||
packages/app-cli/app/command-edit.test.js
|
||||
packages/app-cli/app/command-edit.js
|
||||
packages/app-cli/app/command-exit.js
|
||||
packages/app-cli/app/command-export-sync-status.js
|
||||
@@ -145,7 +141,6 @@ packages/app-cli/app/command-rmnote.test.js
|
||||
packages/app-cli/app/command-rmnote.js
|
||||
packages/app-cli/app/command-search.js
|
||||
packages/app-cli/app/command-server.js
|
||||
packages/app-cli/app/command-set.test.js
|
||||
packages/app-cli/app/command-set.js
|
||||
packages/app-cli/app/command-settingschema.js
|
||||
packages/app-cli/app/command-share.test.js
|
||||
@@ -190,7 +185,6 @@ packages/app-desktop/ElectronAppWrapper.js
|
||||
packages/app-desktop/InteropServiceHelper.js
|
||||
packages/app-desktop/app.reducer.test.js
|
||||
packages/app-desktop/app.reducer.js
|
||||
packages/app-desktop/app.test.js
|
||||
packages/app-desktop/app.js
|
||||
packages/app-desktop/bridge.js
|
||||
packages/app-desktop/checkForUpdates.js
|
||||
@@ -279,8 +273,6 @@ packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
|
||||
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
|
||||
packages/app-desktop/gui/MainLayoutPane.js
|
||||
packages/app-desktop/gui/MainScreen.js
|
||||
packages/app-desktop/gui/MainScreen/handleCallbackUrl.test.js
|
||||
packages/app-desktop/gui/MainScreen/handleCallbackUrl.js
|
||||
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
|
||||
packages/app-desktop/gui/MenuBar.js
|
||||
packages/app-desktop/gui/MultiNoteActions.js
|
||||
@@ -333,7 +325,6 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
|
||||
@@ -350,13 +341,10 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/GroupNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/handlePositions.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/stripUnsafeStyles.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
|
||||
packages/app-desktop/gui/NoteEditor/StatusBar.js
|
||||
packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
|
||||
@@ -399,7 +387,6 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useResourceUnwatcher.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
@@ -447,7 +434,6 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
|
||||
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
|
||||
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
|
||||
packages/app-desktop/gui/NotePropertiesDialog.js
|
||||
packages/app-desktop/gui/NoteRevisionViewer.js
|
||||
packages/app-desktop/gui/NoteSearchBar.js
|
||||
@@ -542,11 +528,10 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/copyNoteExternalLink.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/disableNoteEncryption.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalRedo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalUndo.js
|
||||
@@ -555,7 +540,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/lockEncryptedNotes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
|
||||
@@ -575,7 +559,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/resetLayout.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealInNotebook.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/revealResourceFile.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/search.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/setTags.js
|
||||
@@ -630,20 +613,15 @@ packages/app-desktop/gui/utils/convertToScreenCoordinates.js
|
||||
packages/app-desktop/gui/utils/dragAndDrop.js
|
||||
packages/app-desktop/gui/utils/loadScript.js
|
||||
packages/app-desktop/gulpfile.js
|
||||
packages/app-desktop/integration-tests/chatPanel.spec.js
|
||||
packages/app-desktop/integration-tests/goToAnything.spec.js
|
||||
packages/app-desktop/integration-tests/main.spec.js
|
||||
packages/app-desktop/integration-tests/markdownEditor.spec.js
|
||||
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
|
||||
packages/app-desktop/integration-tests/models/ChatPanel.js
|
||||
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
|
||||
packages/app-desktop/integration-tests/models/GoToAnything.js
|
||||
packages/app-desktop/integration-tests/models/MainScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/BaseEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/EditorCodeDialog.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/MarkdownEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/NoteViewer.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/RichTextEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
@@ -743,7 +721,6 @@ packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
packages/app-mobile/components/BottomDrawer.js
|
||||
packages/app-mobile/components/BottomDrawerMenu.js
|
||||
packages/app-mobile/components/CameraView/ActionButtons.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.jest.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.js
|
||||
@@ -905,8 +882,6 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/expo
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionDescription.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionSelector/SectionTab.js
|
||||
@@ -950,7 +925,6 @@ packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
|
||||
packages/app-mobile/components/screens/LogScreen.js
|
||||
packages/app-mobile/components/screens/Note/Note.test.js
|
||||
packages/app-mobile/components/screens/Note/Note.js
|
||||
packages/app-mobile/components/screens/Note/NoteLockPanel.js
|
||||
packages/app-mobile/components/screens/Note/commands/attachFile.js
|
||||
packages/app-mobile/components/screens/Note/commands/hideKeyboard.js
|
||||
packages/app-mobile/components/screens/Note/commands/index.js
|
||||
@@ -1068,7 +1042,6 @@ packages/app-mobile/utils/database-driver-react-native.web.js
|
||||
packages/app-mobile/utils/debounce.js
|
||||
packages/app-mobile/utils/focusView.js
|
||||
packages/app-mobile/utils/fs-driver/constants.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.test.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.worker.js
|
||||
@@ -1164,6 +1137,8 @@ packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/openLink.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
|
||||
@@ -1194,8 +1169,6 @@ packages/editor/CodeMirror/extensions/rendering/replaceLinks.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceLinks.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeHideReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
|
||||
packages/editor/CodeMirror/extensions/searchExtension.test.js
|
||||
@@ -1214,10 +1187,7 @@ packages/editor/CodeMirror/testing/forceFullParse.js
|
||||
packages/editor/CodeMirror/testing/loadLanguages.js
|
||||
packages/editor/CodeMirror/testing/pressReleaseKey.js
|
||||
packages/editor/CodeMirror/testing/typeText.js
|
||||
packages/editor/CodeMirror/testing/visibleEditorText.js
|
||||
packages/editor/CodeMirror/theme.js
|
||||
packages/editor/CodeMirror/utils/findPositionMatchingLink.test.js
|
||||
packages/editor/CodeMirror/utils/findPositionMatchingLink.js
|
||||
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
|
||||
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.test.js
|
||||
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
|
||||
@@ -1346,6 +1316,9 @@ packages/generator-joplin/generators/app/templates/api/index.js
|
||||
packages/generator-joplin/generators/app/templates/api/noteListType.js
|
||||
packages/generator-joplin/generators/app/templates/api/types.js
|
||||
packages/generator-joplin/generators/app/templates/api_index.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
|
||||
packages/generator-joplin/generators/app/templates/src/index.js
|
||||
packages/generator-joplin/tools/updateCategories.js
|
||||
packages/htmlpack/index.test.js
|
||||
@@ -1363,7 +1336,6 @@ packages/lib/BaseModel.test.js
|
||||
packages/lib/BaseModel.js
|
||||
packages/lib/BaseSyncTarget.js
|
||||
packages/lib/Cache.js
|
||||
packages/lib/ClipperServer.test.js
|
||||
packages/lib/ClipperServer.js
|
||||
packages/lib/CssUtils.js
|
||||
packages/lib/EventDispatcher.test.js
|
||||
@@ -1434,7 +1406,6 @@ packages/lib/components/shared/SamlShared.js
|
||||
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/types.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useIsPublished.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
|
||||
packages/lib/components/shared/SsoScreenShared.js
|
||||
@@ -1449,7 +1420,6 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
|
||||
packages/lib/components/shared/dropbox-login-shared.js
|
||||
packages/lib/components/shared/note-screen-shared.test.js
|
||||
packages/lib/components/shared/note-screen-shared.js
|
||||
packages/lib/components/shared/reduxSharedMiddleware.js
|
||||
packages/lib/components/shared/side-menu-shared.test.js
|
||||
@@ -1525,7 +1495,6 @@ packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/ConflictNoteState.js
|
||||
packages/lib/models/Folder.publishing.test.js
|
||||
packages/lib/models/Folder.sharing.test.js
|
||||
packages/lib/models/Folder.test.js
|
||||
packages/lib/models/Folder.js
|
||||
@@ -1643,13 +1612,13 @@ packages/lib/services/ai/SearchService.js
|
||||
packages/lib/services/ai/SqliteVec.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.js
|
||||
packages/lib/services/ai/applyNoteEdits.js
|
||||
packages/lib/services/ai/availability.test.js
|
||||
packages/lib/services/ai/availability.js
|
||||
packages/lib/services/ai/chunker.test.js
|
||||
packages/lib/services/ai/chunker.js
|
||||
packages/lib/services/ai/classification.js
|
||||
packages/lib/services/ai/noteChat.test.js
|
||||
packages/lib/services/ai/noteChat.tools.test.js
|
||||
packages/lib/services/ai/noteChat.js
|
||||
packages/lib/services/ai/providers/Anthropic.test.js
|
||||
packages/lib/services/ai/providers/Anthropic.js
|
||||
@@ -1660,26 +1629,6 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
|
||||
packages/lib/services/ai/providers/TestProvider.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.js
|
||||
packages/lib/services/ai/tools/ToolIndex.js
|
||||
packages/lib/services/ai/tools/buildEditorTools.js
|
||||
packages/lib/services/ai/tools/global/createNote.js
|
||||
packages/lib/services/ai/tools/global/createNotebook.js
|
||||
packages/lib/services/ai/tools/global/deleteNote.js
|
||||
packages/lib/services/ai/tools/global/index.js
|
||||
packages/lib/services/ai/tools/global/listNotebooks.js
|
||||
packages/lib/services/ai/tools/global/listTags.js
|
||||
packages/lib/services/ai/tools/global/manageTags.js
|
||||
packages/lib/services/ai/tools/global/readImage.js
|
||||
packages/lib/services/ai/tools/global/readNote.js
|
||||
packages/lib/services/ai/tools/global/searchNotes.js
|
||||
packages/lib/services/ai/tools/global/semanticSearchNotes.js
|
||||
packages/lib/services/ai/tools/global/updateNote.js
|
||||
packages/lib/services/ai/tools/types.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.js
|
||||
packages/lib/services/ai/tools/utils/buildTool.js
|
||||
packages/lib/services/ai/tools/utils/serializeToolOutput.js
|
||||
packages/lib/services/ai/tools/utils/settings.js
|
||||
packages/lib/services/ai/types.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
@@ -1691,19 +1640,6 @@ packages/lib/services/commands/isEditorCommand.js
|
||||
packages/lib/services/commands/propsHaveChanged.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.test.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.js
|
||||
packages/lib/services/conflict/autoMergeNote.test.js
|
||||
packages/lib/services/conflict/autoMergeNote.js
|
||||
packages/lib/services/conflict/boundedDiff3.test.js
|
||||
packages/lib/services/conflict/boundedDiff3.js
|
||||
packages/lib/services/conflict/decryptNoteInMemory.js
|
||||
packages/lib/services/conflict/diffNotes.test.js
|
||||
packages/lib/services/conflict/diffNotes.js
|
||||
packages/lib/services/conflict/isAutoMergeEnabled.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
|
||||
@@ -1756,15 +1692,12 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js
|
||||
packages/lib/services/interop/InteropService_Importer_Base.js
|
||||
packages/lib/services/interop/InteropService_Importer_Custom.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToHtml.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.js
|
||||
packages/lib/services/interop/InteropService_Importer_Jex.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
|
||||
packages/lib/services/interop/InteropService_Importer_Obsidian.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Obsidian.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.js
|
||||
@@ -1786,6 +1719,17 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||
packages/lib/services/mcp/McpServer.test.js
|
||||
packages/lib/services/mcp/McpServer.js
|
||||
packages/lib/services/mcp/registry.js
|
||||
packages/lib/services/mcp/tools/createNote.js
|
||||
packages/lib/services/mcp/tools/createNotebook.js
|
||||
packages/lib/services/mcp/tools/deleteNote.js
|
||||
packages/lib/services/mcp/tools/listNotebooks.js
|
||||
packages/lib/services/mcp/tools/listTags.js
|
||||
packages/lib/services/mcp/tools/manageTags.js
|
||||
packages/lib/services/mcp/tools/readNote.js
|
||||
packages/lib/services/mcp/tools/searchNotes.js
|
||||
packages/lib/services/mcp/tools/semanticSearchNotes.js
|
||||
packages/lib/services/mcp/tools/updateNote.js
|
||||
packages/lib/services/mcp/types.js
|
||||
packages/lib/services/noteList/checkboxPieCss.js
|
||||
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
|
||||
@@ -1803,10 +1747,7 @@ packages/lib/services/noteLock/NoteLockService.test.js
|
||||
packages/lib/services/noteLock/NoteLockService.js
|
||||
packages/lib/services/noteLock/NoteLockSession.test.js
|
||||
packages/lib/services/noteLock/NoteLockSession.js
|
||||
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
|
||||
packages/lib/services/noteLock/isNoteLockEnabled.js
|
||||
packages/lib/services/noteLock/setNoteLockState.test.js
|
||||
packages/lib/services/noteLock/setNoteLockState.js
|
||||
packages/lib/services/ocr/OcrDriverBase.js
|
||||
packages/lib/services/ocr/OcrService.test.js
|
||||
packages/lib/services/ocr/OcrService.js
|
||||
@@ -1909,7 +1850,6 @@ packages/lib/services/rest/routes/notes.test.js
|
||||
packages/lib/services/rest/routes/notes.js
|
||||
packages/lib/services/rest/routes/ping.js
|
||||
packages/lib/services/rest/routes/resources.js
|
||||
packages/lib/services/rest/routes/revisions.test.js
|
||||
packages/lib/services/rest/routes/revisions.js
|
||||
packages/lib/services/rest/routes/search.js
|
||||
packages/lib/services/rest/routes/tags.js
|
||||
@@ -1923,7 +1863,6 @@ packages/lib/services/rest/utils/readonlyProperties.js
|
||||
packages/lib/services/rest/utils/requestFields.js
|
||||
packages/lib/services/rest/utils/requestPaginationOptions.js
|
||||
packages/lib/services/search/SearchEngine.resources.test.js
|
||||
packages/lib/services/search/SearchEngine.semantic.test.js
|
||||
packages/lib/services/search/SearchEngine.test.js
|
||||
packages/lib/services/search/SearchEngine.js
|
||||
packages/lib/services/search/SearchEngineUtils.test.js
|
||||
@@ -1953,7 +1892,6 @@ packages/lib/services/synchronizer/ItemUploader.test.js
|
||||
packages/lib/services/synchronizer/ItemUploader.js
|
||||
packages/lib/services/synchronizer/LockHandler.js
|
||||
packages/lib/services/synchronizer/MigrationHandler.js
|
||||
packages/lib/services/synchronizer/Synchronizer.autoMerge.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.basics.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
|
||||
@@ -2005,7 +1943,6 @@ packages/lib/shim-init-node.js
|
||||
packages/lib/shim.js
|
||||
packages/lib/string-utils.test.js
|
||||
packages/lib/string-utils.js
|
||||
packages/lib/testing/ai/semanticSearch.js
|
||||
packages/lib/testing/dom-test-environment.js
|
||||
packages/lib/testing/plugins/createTestPlugin.js
|
||||
packages/lib/testing/share/makeMockShareInvitation.js
|
||||
@@ -2161,7 +2098,6 @@ packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/eslint-rules/describeFilename.test.js
|
||||
packages/tools/fuzzer/ActionRunner.js
|
||||
packages/tools/fuzzer/Fuzzer.js
|
||||
packages/tools/fuzzer/cli.js
|
||||
@@ -2239,8 +2175,8 @@ packages/tools/updateMarkdownDoc.js
|
||||
packages/tools/utils/discourse.test.js
|
||||
packages/tools/utils/discourse.js
|
||||
packages/tools/utils/loadSponsors.js
|
||||
packages/tools/utils/parsePluralLocalizationForm.test.js
|
||||
packages/tools/utils/parsePluralLocalizationForm.js
|
||||
packages/tools/utils/parsePlurallLocalizationForm.test.js
|
||||
packages/tools/utils/translation.js
|
||||
packages/tools/validateFilenames.js
|
||||
packages/tools/website/build.js
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# This patch's goal is to work around an issue in the NSIS uninstaller on Windows:
|
||||
# - For future uninstallers, this patch backports an upstream commit that changes how
|
||||
# running copies of the app are found.
|
||||
# - See https://github.com/electron-userland/electron-builder/pull/8133
|
||||
# - If an existing uninstaller fails, gives an option to continue with the installation
|
||||
# despite the failure.
|
||||
# - Updates "uninstall failed" error messages to state that uninstallation failed (rather
|
||||
# than incorrectly stating that the issue was with closing the app).
|
||||
#
|
||||
# See https://github.com/laurent22/joplin/pull/11541
|
||||
diff --git a/templates/nsis/include/allowOnlyOneInstallerInstance.nsh b/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
|
||||
index fe5d45c730f36c9fe8d8cfea12e242e501b67139..97b27fce6798e30e3e631221435f09b3579e77c3 100644
|
||||
--- a/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
|
||||
+++ b/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
|
||||
@@ -42,7 +42,7 @@
|
||||
${nsProcess::FindProcess} "${_FILE}" ${_ERR}
|
||||
!else
|
||||
# find process owned by current user
|
||||
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c tasklist /FI "USERNAME eq %USERNAME%" /FI "IMAGENAME eq ${_FILE}" /FO csv | %SYSTEMROOT%\System32\find.exe "${_FILE}"`
|
||||
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c tasklist /FI "USERNAME eq %USERNAME%" /FI "IMAGENAME eq ${_FILE}" /FO csv | "$SYSDIR\find.exe" "${_FILE}"`
|
||||
Pop ${_ERR}
|
||||
!endif
|
||||
!macroend
|
||||
@@ -73,7 +73,7 @@
|
||||
!ifdef INSTALL_MODE_PER_ALL_USERS
|
||||
nsExec::Exec `taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid"`
|
||||
!else
|
||||
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
|
||||
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
|
||||
!endif
|
||||
# to ensure that files are not "in-use"
|
||||
Sleep 300
|
||||
@@ -91,7 +91,7 @@
|
||||
!ifdef INSTALL_MODE_PER_ALL_USERS
|
||||
nsExec::Exec `taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid"`
|
||||
!else
|
||||
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
|
||||
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
|
||||
!endif
|
||||
!insertmacro FIND_PROCESS "${APP_EXECUTABLE_FILENAME}" $R0
|
||||
${If} $R0 == 0
|
||||
diff --git a/templates/nsis/include/installUtil.nsh b/templates/nsis/include/installUtil.nsh
|
||||
index 47367741632726ba0886ac516461dbe98b7aea58..675965762375925a505ca6d8bbb67507ef696c2e 100644
|
||||
--- a/templates/nsis/include/installUtil.nsh
|
||||
+++ b/templates/nsis/include/installUtil.nsh
|
||||
@@ -126,10 +126,11 @@ Function handleUninstallResult
|
||||
Return
|
||||
|
||||
${if} $R0 != 0
|
||||
- MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
|
||||
+ # MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
|
||||
DetailPrint `Uninstall was not successful. Uninstaller error code: $R0.`
|
||||
- SetErrorLevel 2
|
||||
- Quit
|
||||
+ DetailPrint `Continuing anyway. See https://github.com/laurent22/joplin/pull/11612.`
|
||||
+ # SetErrorLevel 2
|
||||
+ # Quit
|
||||
${endif}
|
||||
FunctionEnd
|
||||
|
||||
@@ -216,11 +217,13 @@ Function uninstallOldVersion
|
||||
IntOp $R5 $R5 + 1
|
||||
|
||||
${if} $R5 > 5
|
||||
- MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY OneMoreAttempt
|
||||
- Return
|
||||
+ MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeUninstalled)" /SD IDCANCEL IDRETRY ContinueWithoutUninstall
|
||||
+ Abort ; Exit early
|
||||
+ ContinueWithoutUninstall:
|
||||
+ Return
|
||||
${endIf}
|
||||
|
||||
- OneMoreAttempt:
|
||||
+# OneMoreAttempt: ; Commented out because unused
|
||||
ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0
|
||||
ifErrors TryInPlace CheckResult
|
||||
|
||||
diff --git a/templates/nsis/messages.yml b/templates/nsis/messages.yml
|
||||
index a1c2847fa48d79f835b30b48e999ccaf3c818657..6884c18d1e77dbd6be114401d23cf5caf3e0dd94 100644
|
||||
--- a/templates/nsis/messages.yml
|
||||
+++ b/templates/nsis/messages.yml
|
||||
@@ -235,3 +235,8 @@ uninstallFailed:
|
||||
sv: Det gick inte att avinstallera gamla programfiler. Försök att köra installationsprogrammet igen.
|
||||
uk: Не вдалось видалити старі файли застосунку. Будь ласка, спробуйте запустити встановлювач знов.
|
||||
zh_TW: 無法俺安裝舊的應用程式檔案。 請嘗試再次執行安裝程式。
|
||||
+
|
||||
+
|
||||
+appCannotBeUninstalled:
|
||||
+ en: "The old version of ${PRODUCT_NAME} could not be removed. \nClick Retry to skip this step."
|
||||
+
|
||||
@@ -1,48 +0,0 @@
|
||||
# Patched to work around a Windows install issue: On Windows, if an existing uninstaller fails,
|
||||
# this change gives an option to continue with the installation despite the failure
|
||||
# (https://github.com/laurent22/joplin/pull/11541).
|
||||
diff --git a/templates/nsis/include/installUtil.nsh b/templates/nsis/include/installUtil.nsh
|
||||
index 47367741632726ba0886ac516461dbe98b7aea58..c40a0907688cd8b1b8d09047cb0b380e42110ea1 100644
|
||||
--- a/templates/nsis/include/installUtil.nsh
|
||||
+++ b/templates/nsis/include/installUtil.nsh
|
||||
@@ -128,8 +128,9 @@ Function handleUninstallResult
|
||||
${if} $R0 != 0
|
||||
- MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
|
||||
+ # MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
|
||||
DetailPrint `Uninstall was not successful. Uninstaller error code: $R0.`
|
||||
- SetErrorLevel 2
|
||||
- Quit
|
||||
+ DetailPrint `Continuing anyway. See https://github.com/laurent22/joplin/pull/11612.`
|
||||
+ # SetErrorLevel 2
|
||||
+ # Quit
|
||||
${endif}
|
||||
FunctionEnd
|
||||
|
||||
@@ -216,11 +217,13 @@ Function uninstallOldVersion
|
||||
IntOp $R5 $R5 + 1
|
||||
|
||||
${if} $R5 > 5
|
||||
- MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY OneMoreAttempt
|
||||
- Return
|
||||
+ MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeUninstalled)" /SD IDCANCEL IDRETRY ContinueWithoutUninstall
|
||||
+ Abort ; Exit early
|
||||
+ ContinueWithoutUninstall:
|
||||
+ Return
|
||||
${endIf}
|
||||
|
||||
- OneMoreAttempt:
|
||||
+# OneMoreAttempt: ; OneMoreAttempt is unused
|
||||
ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0
|
||||
ifErrors TryInPlace CheckResult
|
||||
|
||||
diff --git a/templates/nsis/messages.yml b/templates/nsis/messages.yml
|
||||
index 982e1b09e424c4f07ce7c1152fe5ce8b703f5f57..20dfa5743d18f8775ce88658014092cf977ad055 100644
|
||||
--- a/templates/nsis/messages.yml
|
||||
+++ b/templates/nsis/messages.yml
|
||||
@@ -238,3 +238,5 @@ uninstallFailed:
|
||||
appClosing:
|
||||
en: "Closing running ${PRODUCT_NAME}..."
|
||||
cs: "Ukončuji aplikaci ${PRODUCT_NAME}..."
|
||||
+appCannotBeUninstalled:
|
||||
+ en: "The old version of ${PRODUCT_NAME} could not be removed. \nClick Retry to skip this step."
|
||||
\ No newline at end of file
|
||||
@@ -1212,13 +1212,6 @@ footer .bottom-links-row p {
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
.price-container p.pricing-table-billed-annually {
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -1311,15 +1304,6 @@ footer .bottom-links-row p {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.cfa-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.cfa-description {
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.price-row .plan-type {
|
||||
font-size: 16px;
|
||||
@@ -1408,3 +1392,8 @@ footer .bottom-links-row p {
|
||||
:lang(zh-cn) #plans-section .faq {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.cfa-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 700 KiB |
|
Before Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 575 KiB |
@@ -1,61 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 31 Aug 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 31 Aug 2026 00:00:00 GMT</pubDate><item><title><![CDATA[What's new in Joplin 3.7]]></title><description><![CDATA[<p>Joplin 3.7 is the release where AI arrives in the app. A new chat panel lets you ask questions about the note you're working on, semantic search finds notes by meaning rather than by keyword, and a built-in MCP server allows external tools to work with your notes.</p>
|
||||
<p>Alongside this, the desktop app gains whiteboards - a new way to lay out notes and ideas visually - while mobile continues its interface redesign. Both platforms benefit from a more capable Markdown table editor and a number of sync reliability improvements.</p>
|
||||
<h2>Improvements across desktop and mobile<a name="improvements-across-desktop-and-mobile" href="#improvements-across-desktop-and-mobile" class="heading-anchor">🔗</a></h2>
|
||||
<h3>A real table editor in the Markdown editor<a name="a-real-table-editor-in-the-markdown-editor" href="#a-real-table-editor-in-the-markdown-editor" class="heading-anchor">🔗</a></h3>
|
||||
<p>Editing Markdown tables has always been the least pleasant part of writing in plain text. Joplin 3.7 adds an interactive table editor to the Markdown editor, with a context menu to add and delete rows and columns.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-table-editor.png" alt="The Joplin Markdown editor showing a table with the table context menu open, listing the commands to add and delete rows and columns."></p>
|
||||
<p>Because the editor is shared between platforms, this is available on mobile as well.</p>
|
||||
<h3>More reliable syncing<a name="more-reliable-syncing" href="#more-reliable-syncing" class="heading-anchor">🔗</a></h3>
|
||||
<p>Syncing is faster and safer this cycle. Deleting a lot of notes at once on Joplin Server and Joplin Cloud no longer takes as long, and if you happen to be typing in a note just as it's being updated from another device, your changes are no longer at risk of being overwritten.</p>
|
||||
<h3>Faster startup<a name="faster-startup" href="#faster-startup" class="heading-anchor">🔗</a></h3>
|
||||
<p>Joplin no longer processes plugin files that don't need processing at startup, which noticeably reduces launch time for users with several plugins installed.</p>
|
||||
<h2>Desktop-specific improvements<a name="desktop-specific-improvements" href="#desktop-specific-improvements" class="heading-anchor">🔗</a></h2>
|
||||
<h3>AI features (Beta)<a name="ai-features-beta" href="#ai-features-beta" class="heading-anchor">🔗</a></h3>
|
||||
<p>The headline addition of this release is a set of AI features built into the app.</p>
|
||||
<p>The <strong>chat panel</strong> sits alongside the current note and can answer questions about it, summarise it, or make edits to it on your behalf, as well as read images in your notes.</p>
|
||||
<p>To use the AI features you need a provider, and this release introduces <strong>Joplin Cloud AI</strong> to make that as simple as possible. If you already sync with Joplin Cloud, you can simply turn it on and start using the chat panel right away - there's no API key to obtain, and nothing to configure.</p>
|
||||
<p>If you would rather use your own provider, that remains possible: the chat panel also works with your own OpenAI or Anthropic key. It can equally be pointed at a local model running on your own machine, in which case your notes never leave your computer at all.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-ai-chat.png" alt="The Joplin desktop app with the AI chat panel open beside a note, showing a question about the note and the reply from the model."></p>
|
||||
<p><strong>Semantic search</strong> is now available in the sidebar and in Go To Anything. Unlike the regular search, it matches on meaning rather than exact words, so a search for "holiday plans" can surface a note titled "Trip to Portugal".</p>
|
||||
<p>This is powered by a new note embeddings indexer, which runs in the background and indexes both note titles and content.</p>
|
||||
<p>Finally, all of this is available to plugin authors too. Plugins can now hold conversations with a model, run semantic searches, and work with the note index directly - which opens the door to a whole new class of plugins, from automatic tagging and summarising to tools that surface related notes as you write.</p>
|
||||
<p>All the AI features are marked as <strong>Beta</strong>: they work, but they are new, and we expect to refine them based on how people actually use them.</p>
|
||||
<h3>MCP server<a name="mcp-server" href="#mcp-server" class="heading-anchor">🔗</a></h3>
|
||||
<p>Joplin now includes an <a href="https://modelcontextprotocol.io/">MCP server</a>, which means external AI tools and assistants can read from and write to your notes through a standard protocol. If you already use an MCP-capable app such as Claude, it can now work directly with your Joplin data.</p>
|
||||
<h3>Whiteboards<a name="whiteboards" href="#whiteboards" class="heading-anchor">🔗</a></h3>
|
||||
<p>A brand new feature on desktop: whiteboards let you arrange notes, files and free-form cards on an infinite canvas, and connect them with labelled arrows.</p>
|
||||
<p>Notebooks and tags are good at filing things away, but they say little about how your notes relate to one another. A whiteboard gives you that missing view: you can map out a project, plan a piece of writing, or lay out the pieces of a problem and see the connections between them at a glance.</p>
|
||||
<p>Cards and groups can be coloured, and files can be dragged and dropped straight onto the canvas.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-whiteboard.png" alt="A Joplin whiteboard with several coloured cards linked to notes, arranged on the canvas and connected by labelled arrows."></p>
|
||||
<h3>Importing from other apps<a name="importing-from-other-apps" href="#importing-from-other-apps" class="heading-anchor">🔗</a></h3>
|
||||
<p>Joplin 3.7 adds support for importing an Obsidian vault, on both desktop and the CLI, making it straightforward to move over from Obsidian while keeping your notes and their structure.</p>
|
||||
<p>The Evernote importer now resolves cross-notebook links in many cases where they were previously lost.</p>
|
||||
<p>The OneNote importer also received another round of fixes, in particular around note order, printouts and handwriting.</p>
|
||||
<h3>Publishing and sharing<a name="publishing-and-sharing" href="#publishing-and-sharing" class="heading-anchor">🔗</a></h3>
|
||||
<p>Joplin Cloud users could already publish a note to the internet as a public page. It is now possible to publish a <strong>whole notebook</strong> at once.</p>
|
||||
<p>The notes are published together, with their links between each other preserved, which effectively turns a notebook into a small website: a piece of documentation, a recipe collection, a set of course notes, or anything else you want to share as a whole rather than one page at a time. Anyone with the link can read it, and it stays up to date as you edit the notes in Joplin.</p>
|
||||
<p>And when sharing a notebook, email addresses are now auto-completed, which makes sharing with the same people repeatedly much quicker.</p>
|
||||
<h3>Settings search<a name="settings-search" href="#settings-search" class="heading-anchor">🔗</a></h3>
|
||||
<p>Joplin has accumulated a lot of settings over the years, and it is not always obvious which section holds the one you need. The configuration screen now has a search box: type what you are after, and the matching settings appear, wherever they happen to live.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-setting-search.png" alt="The Joplin configuration screen with a term typed into the new search box and the matching settings listed below it."></p>
|
||||
<h3>Post-quantum encryption for connections<a name="post-quantum-encryption-for-connections" href="#post-quantum-encryption-for-connections" class="heading-anchor">🔗</a></h3>
|
||||
<p>Joplin desktop now supports post-quantum cryptography (PQC) for TLS connections. This protects your synchronisation traffic against "harvest now, decrypt later" attacks, where encrypted data captured today could be decrypted by a quantum computer in the future.</p>
|
||||
<h3>Easier installation on Linux<a name="easier-installation-on-linux" href="#easier-installation-on-linux" class="heading-anchor">🔗</a></h3>
|
||||
<p>The Linux AppImage no longer depends on the deprecated <code>libfuse2</code> library. On many distributions, installing Joplin used to mean first tracking down and installing that dependency by hand - it should now simply run out of the box.</p>
|
||||
<h2>Mobile-specific improvements<a name="mobile-specific-improvements" href="#mobile-specific-improvements" class="heading-anchor">🔗</a></h2>
|
||||
<h3>The interface redesign continues<a name="the-interface-redesign-continues" href="#the-interface-redesign-continues" class="heading-anchor">🔗</a></h3>
|
||||
<p>The mobile redesign that started in previous releases carries on through 3.7, this time focusing on the menus you reach for constantly - creating a note, acting on one, or long-pressing a notebook. They are now easier to read at a glance, and their options are laid out more sensibly, so common actions take less hunting. The search screen has had the same treatment.</p>
|
||||
<p>Dialogs have been tidied up too, with buttons always appearing in the same order throughout the app - a small thing, but it means you stop having to check which side "Cancel" is on before tapping.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-mobile-new-note.png" alt="The redesigned new note menu on Joplin mobile."></p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260831-mobile-note-menu.png" alt="The redesigned note actions menu on Joplin mobile."></p>
|
||||
<h3>Faster encryption<a name="faster-encryption" href="#faster-encryption" class="heading-anchor">🔗</a></h3>
|
||||
<p>The native crypto module has been refactored, which makes encryption and decryption substantially faster on mobile. This is most noticeable when first setting up end-to-end encryption or syncing a large collection for the first time.</p>
|
||||
<h3>Notebook sort options<a name="notebook-sort-options" href="#notebook-sort-options" class="heading-anchor">🔗</a></h3>
|
||||
<p>You can now choose how the notebook list itself is ordered, from the configuration screen: alphabetically, or by which notebook was most recently updated - and in either direction. Sorting by recent activity is particularly handy on mobile, as it keeps the notebooks you are actually working in at the top of the sidebar.</p>
|
||||
<h2>Bug fixes and security fixes<a name="bug-fixes-and-security-fixes" href="#bug-fixes-and-security-fixes" class="heading-anchor">🔗</a></h2>
|
||||
<p>Beyond the new features, a lot of this release went into making the apps more reliable and more secure: Joplin 3.7 includes <strong>over 100 bug fixes</strong> and <strong>eight security fixes</strong> across desktop and mobile.</p>
|
||||
<p>For the full list of changes, see <a href="https://joplinapp.org/help/about/changelog/desktop/">the desktop changelog</a> and <a href="https://joplinapp.org/help/about/changelog/android/">the mobile changelog</a>.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20260831-release-3-7</link><guid isPermaLink="false">20260831-release-3-7</guid><pubDate>Mon, 31 Aug 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[What's new in Joplin 3.6]]></title><description><![CDATA[<p>Joplin 3.6 brings a more polished editing experience, a much improved OneNote importer, and a clearer view of what's happening when the app syncs. Mobile also gains a number of features that previously were only available on desktop.</p>
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Tue, 05 May 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Tue, 05 May 2026 00:00:00 GMT</pubDate><item><title><![CDATA[What's new in Joplin 3.6]]></title><description><![CDATA[<p>Joplin 3.6 brings a more polished editing experience, a much improved OneNote importer, and a clearer view of what's happening when the app syncs. Mobile also gains a number of features that previously were only available on desktop.</p>
|
||||
<h2>Improvements across desktop and mobile<a name="improvements-across-desktop-and-mobile" href="#improvements-across-desktop-and-mobile" class="heading-anchor">🔗</a></h2>
|
||||
<h3>A more visual Markdown editor<a name="a-more-visual-markdown-editor" href="#a-more-visual-markdown-editor" class="heading-anchor">🔗</a></h3>
|
||||
<p>The Markdown editor now renders formatting directly in the editor by default, which means headings, bold and italic text, links, images and other elements now appear styled as you type, instead of as raw Markdown. This brings the editing experience much closer to the final rendered note, while keeping all the speed and predictability of plain Markdown.</p>
|
||||
@@ -598,4 +541,46 @@ sys 0m38.013s</p>
|
||||
<li>Android: <a href="https://joplinapp.org/help/about/changelog/android">https://joplinapp.org/help/about/changelog/android</a></li>
|
||||
<li>iOS: <a href="https://joplinapp.org/help/about/changelog/ios">https://joplinapp.org/help/about/changelog/ios</a></li>
|
||||
</ul>
|
||||
]]></description><link>https://joplinapp.org/news/20230508-release-2-11</link><guid isPermaLink="false">20230508-release-2-11</guid><pubDate>Fri, 14 Jul 2023 12:00:00 GMT</pubDate><twitter-text>What's new in Joplin 2.11</twitter-text></item></channel></rss>
|
||||
]]></description><link>https://joplinapp.org/news/20230508-release-2-11</link><guid isPermaLink="false">20230508-release-2-11</guid><pubDate>Fri, 14 Jul 2023 12:00:00 GMT</pubDate><twitter-text>What's new in Joplin 2.11</twitter-text></item><item><title><![CDATA[What's new in Joplin 2.10]]></title><description><![CDATA[<p>Great news! Joplin 2.10 is here and we've made some amazing improvements and bug fixes, with a focus on the mobile app.</p>
|
||||
<h2>New design for "New note" and "New to-do" buttons<a name="new-design-for-new-note-and-new-to-do-buttons" href="#new-design-for-new-note-and-new-to-do-buttons" class="heading-anchor">🔗</a></h2>
|
||||
<p>We're excited to announce that we've made it even easier to create new notes and to-do lists with new designs for the "New note" and "New to-do" buttons.</p>
|
||||
<p>If there is enough space, the button labels will be shown in full:</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230508-new-note-1.png" alt=""></p>
|
||||
<p>While for those who prefer a more narrow note list, only the button icons will be shown:</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230508-new-note-2.png" alt=""></p>
|
||||
<p>It's a small improvement, but we're confident it will make the app even more intuitive for new users.</p>
|
||||
<h2>Fixes and improvements<a name="fixes-and-improvements" href="#fixes-and-improvements" class="heading-anchor">🔗</a></h2>
|
||||
<p>This version includes 30 bug fixes and 16 general improvements. Let's dive into some of the highlights:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>Self Not Found and Pedro have been working tirelessly on improving the pasting of plain text in the application, and we're happy to say that there is now a brand new "Paste as text" option in the Edit menu.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>For our amazing plugin developers out there, we have added a few new APIs and fixed a bug that was preventing certain plugins from starting. You can now get even more creative with your plugins!</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Tao Klerks, has been hard at work fixing and improving the custom sort order of the note list. No more notes in the wrong position when you drop them! Plus, custom sort order is now synchronised too.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>We've also fixed a few bugs with our Web Clipper, including an issue where certain pages wouldn't import their images. A new Web Clipper has also been released, so you can clip to your heart's content!</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>Last but not least, we have modernised both the desktop and mobile application modules, just <a href="https://joplinapp.org/news/20221115-renovate/">as we previously announced</a>. Although these changes may not be visible to you, they required a lot of work! But the result is that our applications are now more stable and secure, and it will be easier to maintain them in the long run. We're using a tool called <a href="https://www.mend.io/free-developer-tools/renovate/">Renovate</a>, which will automatically propose package updates that we will review. In total, we've updated a whopping 633 packages so far!</p>
|
||||
<h2>Android app is available in the Play Store<a name="android-app-is-available-in-the-play-store" href="#android-app-is-available-in-the-play-store" class="heading-anchor">🔗</a></h2>
|
||||
<p>Our latest version, 2.10, is now back in the Play Store and ready for download! Although we had to skip 2.9 due to some of Google's requirements, we worked hard to ensure that our app complies with their standards, and we are excited to announce that we are back and better than ever! Our iOS version is also available, so you can continue to enjoy the app regardless of the platform you use.</p>
|
||||
<h3>Biometrics support<a name="biometrics-support" href="#biometrics-support" class="heading-anchor">🔗</a></h3>
|
||||
<p>To make your experience even more secure, our Android and iOS apps now support biometric unlock! With just a quick scan of your fingerprint or Face ID, you can unlock your app in no time. To enable this beta feature, just head over to the settings and click on "Use biometrics to secure access to the app".</p>
|
||||
<p>We've tested this feature thoroughly during prerelease, and have already fixed all known issues. However we still consider it as a beta feature for now, so if you run into any issues please let us know.</p>
|
||||
<h3>Support for multiple profile<a name="support-for-multiple-profile" href="#support-for-multiple-profile" class="heading-anchor">🔗</a></h3>
|
||||
<p>We're thrilled to announce that multiple profiles are now supported in our mobile app! To create a new profile, simply go to the Configuration screen and click on "Manage profiles" under the Tools section.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230508-biometrics-1.png" alt=""></p>
|
||||
<p>From there, you can easily add or remove profiles as needed.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230508-biometrics-2.png" alt=""></p>
|
||||
<p>Once multiple profiles are setup, you will see a new option in the sidebar to quickly toggle from one profile to another:</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230508-biometrics-3.png" alt=""></p>
|
||||
<p>Once you've set up multiple profiles, you can easily toggle between them using the new option in the sidebar. This feature is perfect for separating your personal and work notes into independent collections.</p>
|
||||
<h3>Support for realtime search<a name="support-for-realtime-search" href="#support-for-realtime-search" class="heading-anchor">🔗</a></h3>
|
||||
<p>Our mobile app now has an improved search function that performs text searches in real time! No more waiting for the search results to load, they'll appear instantly as you type.</p>
|
||||
<h3>Improved filesystem sync performance<a name="improved-filesystem-sync-performance" href="#improved-filesystem-sync-performance" class="heading-anchor">🔗</a></h3>
|
||||
<p>Thanks to the hard work of jd1378, the sync no longer freezes during filesystem synchronisation. We know how frustrating that can be, and we're thrilled to have solved this issue. Getting filesystem sync to work on Android is never easy due to the restrictions put in place by Google, especially since they frequently change, but we're committed to delivering the best possible experience for our Android users.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20230508-release-2-10</link><guid isPermaLink="false">20230508-release-2-10</guid><pubDate>Wed, 10 May 2023 12:00:00 GMT</pubDate><twitter-text>What's new in Joplin 2.10</twitter-text></item></channel></rss>
|
||||
@@ -40,16 +40,12 @@
|
||||
{{#pricingTable}}
|
||||
<strong translate>Pricing</strong>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th translate>Users</th><th translate>Price</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#rows}}
|
||||
<tr><td>{{condition}}</td><td>{{priceMonthly}}</td></tr>
|
||||
<tr><td>{{condition}}</td><td>{{priceYearly}}</td></tr>
|
||||
{{/rows}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="pricing-table-billed-annually">{{billedAnnually}}</p>
|
||||
{{/pricingTable}}
|
||||
|
||||
<p class="text-center subscribe-wrapper">
|
||||
@@ -58,10 +54,6 @@
|
||||
{{#learnMoreUrl}}
|
||||
<a id="learnMore-{{name}}" href="{{learnMoreUrl}}" class="button-link btn-white learnMoreButton cfa-button">Learn more</a>
|
||||
{{/learnMoreUrl}}
|
||||
|
||||
{{#cfaDescription}}
|
||||
<p class="cfa-description">{{cfaDescription}}</p>
|
||||
{{/cfaDescription}}
|
||||
</p>
|
||||
|
||||
{{#footnote}}<sub>(*) {{.}}</sub>{{/footnote}}
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
Joplin Server Business
|
||||
</h1>
|
||||
<p translate class="text-center sub-title">
|
||||
Joplin Server Business is the self-hosted solution for businesses and organisations. Install it on your own infrastructure, keeping your data private and secure within your organisation while retaining full control over where it is stored.
|
||||
Joplin Server Business is a synchronisation server that you can install on your own infrastructure, so that your data remains private and secure within your business.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,7 +237,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
const setHostingType = (type, updateUrl = true) => {
|
||||
const setHostingType = (type) => {
|
||||
const other = type === 'managed' ? 'self' : 'managed';
|
||||
$('.toggle-button-' + type).addClass('active');
|
||||
$('.toggle-button-' + type).removeClass('inactive');
|
||||
@@ -246,12 +246,6 @@
|
||||
|
||||
$('.hosting-type-' + type).show();
|
||||
$('.hosting-type-' + other).hide();
|
||||
|
||||
if (updateUrl) {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('hosting', type);
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
$('.toggle-button-managed').click((event) => {
|
||||
@@ -267,6 +261,6 @@
|
||||
});
|
||||
|
||||
const initialHostingType = urlQuery.get('hosting') ? urlQuery.get('hosting') : 'managed';
|
||||
setHostingType(initialHostingType, false);
|
||||
setHostingType(initialHostingType);
|
||||
</script>
|
||||
</div>
|
||||
@@ -102,15 +102,13 @@ WORKDIR /home/$user/packages/server
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["yarn", "start-prod"]
|
||||
|
||||
ARG IMAGE_NAME="Joplin Server"
|
||||
ENV APP_NAME=${IMAGE_NAME}
|
||||
|
||||
# Build-time metadata
|
||||
# https://github.com/opencontainers/image-spec/blob/master/annotations.md
|
||||
ARG BUILD_DATE
|
||||
ARG REVISION
|
||||
ARG VERSION
|
||||
ARG SOURCE
|
||||
ARG IMAGE_NAME="Joplin Server"
|
||||
LABEL org.opencontainers.image.created="$BUILD_DATE" \
|
||||
org.opencontainers.image.title="$IMAGE_NAME" \
|
||||
org.opencontainers.image.description="Docker image for Joplin Server" \
|
||||
|
||||
@@ -172,6 +172,24 @@ elif [[ $ARCHITECTURE =~ .*i386.*|.*i686.* ]]; then
|
||||
fi
|
||||
|
||||
#-----------------------------------------------------
|
||||
print "Checking dependencies..."
|
||||
## Check if libfuse2 is present.
|
||||
if [[ $(command -v ldconfig) ]]; then
|
||||
LIBFUSE=$(ldconfig -p | grep "libfuse.so.2" || echo '')
|
||||
fi
|
||||
if [[ $LIBFUSE == "" ]]; then
|
||||
LIBFUSE=$(find /lib /usr/lib /lib64 /usr/lib64 /usr/local/lib -name "libfuse.so.2" 2>/dev/null | grep "libfuse.so.2" || echo '')
|
||||
fi
|
||||
if [[ $LIBFUSE == "" ]]; then
|
||||
print "${COLOR_RED}Error: Can't get libfuse2 on system, please install libfuse2${COLOR_RESET}"
|
||||
print "See https://joplinapp.org/help/faq/#desktop-application-will-not-launch-on-linux and https://github.com/AppImage/AppImageKit/wiki/FUSE for more information"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#-----------------------------------------------------
|
||||
# Download Joplin
|
||||
#-----------------------------------------------------
|
||||
|
||||
# Get the latest version to download
|
||||
if [[ "$INCLUDE_PRE_RELEASE" == true ]]; then
|
||||
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases" | grep -Po '"tag_name": ?"v\K.*?(?=")' | sort -rV | head -1)
|
||||
@@ -179,27 +197,6 @@ else
|
||||
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases/latest" | grep -Po '"tag_name": ?"v\K.*?(?=")')
|
||||
fi
|
||||
|
||||
#-----------------------------------------------------
|
||||
print "Checking dependencies..."
|
||||
## Check for libfuse2 for Joplin versions lesser than 3.7.10, which transitioned to a new AppImage runtime without libfuse2 dependency
|
||||
if [[ $(compareVersions "$RELEASE_VERSION" "3.7.10") -lt 0 ]]; then
|
||||
if [[ $(command -v ldconfig) ]]; then
|
||||
LIBFUSE=$(ldconfig -p | grep "libfuse.so.2" || echo '')
|
||||
fi
|
||||
if [[ $LIBFUSE == "" ]]; then
|
||||
LIBFUSE=$(find /lib /usr/lib /lib64 /usr/lib64 /usr/local/lib -name "libfuse.so.2" 2>/dev/null | grep "libfuse.so.2" || echo '')
|
||||
fi
|
||||
if [[ $LIBFUSE == "" ]]; then
|
||||
print "${COLOR_RED}Error: Can't get libfuse2 on system, please install libfuse2${COLOR_RESET}"
|
||||
print "See https://joplinapp.org/help/faq/#desktop-application-will-not-launch-on-linux and https://github.com/AppImage/AppImageKit/wiki/FUSE for more information"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
#-----------------------------------------------------
|
||||
# Download Joplin
|
||||
#-----------------------------------------------------
|
||||
|
||||
# Check if it's in the latest version
|
||||
if [[ -e "${INSTALL_DIR}/VERSION" ]]; then
|
||||
CURRENT_VERSION=$(< "${INSTALL_DIR}/VERSION")
|
||||
|
||||
@@ -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://essaypro.com/"><img title="write my essay EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write my essay EssayPro"/></a> <a href="https://writepaper.com/"><img title="write my paper" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="write my paper"/></a> <a href="https://mypaperhelp.com/"><img title="MyPaperHelp" width="256" src="https://joplinapp.org/images/sponsors/MyPaperHelp.png" alt="MyPaperHelp"/></a> <a href="https://essaypay.com/"><img title="EssayPay" width="256" src="https://joplinapp.org/images/sponsors/EssayPay.png" alt="EssayPay"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://socialboom.io/"><img title="Buy YouTube, Instagram, TikTok & 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>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"nodejs": "24.12.0",
|
||||
"pkg-config": "latest",
|
||||
"python": "3.14.3",
|
||||
"python": "3.14.2",
|
||||
"bat": "latest",
|
||||
"electron": {
|
||||
"version": "latest",
|
||||
|
||||
@@ -14,8 +14,6 @@ const github = require('eslint-plugin-github');
|
||||
const { fixupPluginRules, includeIgnoreFile } = require('@eslint/compat');
|
||||
const js = require('@eslint/js');
|
||||
|
||||
const joplin = require('./packages/tools/eslint-rules');
|
||||
|
||||
const { join } = require('path');
|
||||
|
||||
// As of ESLint v9, .eslintignore is no longer supported and the presence of
|
||||
@@ -248,7 +246,6 @@ module.exports = defineConfig([{
|
||||
promise,
|
||||
jest,
|
||||
github: fixupPluginRules(github),
|
||||
joplin,
|
||||
},
|
||||
}, {
|
||||
files: [
|
||||
@@ -260,12 +257,6 @@ module.exports = defineConfig([{
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
}, {
|
||||
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.test.js', '**/*.test.jsx'],
|
||||
|
||||
rules: {
|
||||
'joplin/describe-filename': ['error'],
|
||||
},
|
||||
}, {
|
||||
// .jsx files should be linted with the default rules
|
||||
files: ['**/*.jsx'],
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12",
|
||||
"yarn": "4.14.1"
|
||||
"yarn": "4.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"buildApiDoc": "yarn workspace joplin start apidoc ../../readme/api/references/rest_api.md",
|
||||
@@ -77,8 +77,8 @@
|
||||
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@types/jest": "29.5.14",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.1",
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.0",
|
||||
"@typescript-eslint/parser": "8.60.0",
|
||||
"cspell": "5.21.2",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-interactive": "14.0.0",
|
||||
@@ -87,7 +87,7 @@
|
||||
"eslint-plugin-promise": "7.3.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "11.3.5",
|
||||
"fs-extra": "11.3.4",
|
||||
"glob": "11.1.0",
|
||||
"globals": "17.6.0",
|
||||
"gulp": "4.0.2",
|
||||
@@ -107,20 +107,22 @@
|
||||
},
|
||||
"packageManager": "yarn@4.16.0",
|
||||
"resolutions": {
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.42.1",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/lint": "6.9.5",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
"onnxruntime-node": "1.26.0",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"eslint": "patch:eslint@9.39.4#./.yarn/patches/eslint-npm-9.39.4-81a84865c0.patch",
|
||||
"yauzl": "3.3.2",
|
||||
"yauzl": "3.3.1",
|
||||
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
||||
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
||||
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
|
||||
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
|
||||
"chokidar@^2.0.0": "3.5.3",
|
||||
"rn-fetch-blob@0.12.0": "patch:rn-fetch-blob@npm%3A0.12.0#./.yarn/patches/rn-fetch-blob-npm-0.12.0-cf02e3c544.patch",
|
||||
"app-builder-lib@26.0.0-alpha.7": "patch:app-builder-lib@npm%3A26.0.0-alpha.7#./.yarn/patches/app-builder-lib-npm-26.0.0-alpha.7-e1b3dca119.patch",
|
||||
"app-builder-lib@24.13.3": "patch:app-builder-lib@npm%3A24.13.3#./.yarn/patches/app-builder-lib-npm-24.13.3-86a66c0bf3.patch",
|
||||
"react-native-sqlite-storage@6.0.1": "patch:react-native-sqlite-storage@npm%3A6.0.1#./.yarn/patches/react-native-sqlite-storage-npm-6.0.1-8369d747bd.patch",
|
||||
"react-native-paper@5.13.1": "patch:react-native-paper@npm%3A5.13.1#./.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch",
|
||||
"react-native-popup-menu@0.17.0": "patch:react-native-popup-menu@npm%3A0.17.0#./.yarn/patches/react-native-popup-menu-npm-0.17.0-8b745d88dd.patch",
|
||||
@@ -133,7 +135,7 @@
|
||||
"node-gyp@npm:^9.0.0": "11.2.0",
|
||||
"node-forge@npm:^1": "1.3.2",
|
||||
"node-forge@npm:^1.3.0": "1.3.2",
|
||||
"moment@npm:^2.8.4": "2.30.1",
|
||||
"moment@npm:^2.8.4": "2.29.4",
|
||||
"depd@npm:^2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"depd@npm:~2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"depd@npm:~1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
@@ -145,7 +147,6 @@
|
||||
"@react-native-vector-icons/fontawesome5@npm:12.3.0": "patch:@react-native-vector-icons/fontawesome5@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-fontawesome5-npm-12.3.0-a1ca46610f.patch",
|
||||
"@react-native-vector-icons/ionicons@npm:12.3.0": "patch:@react-native-vector-icons/ionicons@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-ionicons-npm-12.3.0-9bd4746f3f.patch",
|
||||
"@react-native-vector-icons/material-design-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-design-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-design-icons-npm-12.4.0-890f7f618b.patch",
|
||||
"@react-native-vector-icons/material-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-icons-npm-12.4.0-94138e627b.patch",
|
||||
"app-builder-lib@npm:26.15.6": "patch:app-builder-lib@npm%3A26.15.6#~/.yarn/patches/app-builder-lib-npm-26.15.6-010811df92.patch"
|
||||
"@react-native-vector-icons/material-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-icons-npm-12.4.0-94138e627b.patch"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { reg } from '@joplin/lib/registry.js';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Stdout can be called with formatted strings or arbitrary values
|
||||
type StdoutFn = (text: any)=> void;
|
||||
@@ -20,9 +18,8 @@ export default class BaseCommand {
|
||||
throw new Error('Usage not defined');
|
||||
}
|
||||
|
||||
public encryptionCheck(item: { encryption_applied?: number; is_locked?: number } | null) {
|
||||
public encryptionCheck(item: { encryption_applied?: number } | null) {
|
||||
if (item && item.encryption_applied) throw new Error(_('Cannot change encrypted item'));
|
||||
if (isNoteLockEnabled() && NoteLockNote.isLocked(item)) throw new Error(_('Cannot change a locked note'));
|
||||
}
|
||||
|
||||
public description(): string {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
|
||||
const Command = require('./command-cat');
|
||||
|
||||
describe('command-cat', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setupApplication();
|
||||
});
|
||||
|
||||
it('should refuse to display a locked note', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
|
||||
|
||||
let output = '';
|
||||
const command = setupCommandForTesting(Command, (text: string) => { output += text; });
|
||||
|
||||
await expect(command.action({ note: note.id, options: {} })).rejects.toThrow('locked note');
|
||||
expect(output).toBe('');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -4,8 +4,6 @@ import { _ } from '@joplin/lib/locale';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
@@ -25,7 +23,6 @@ class Command extends BaseCommand {
|
||||
|
||||
const item = await app().loadItem(ModelType.Note, title, { parent: app().currentFolder() });
|
||||
if (!item) throw new Error(_('Cannot find "%s".', title));
|
||||
if (isNoteLockEnabled() && NoteLockNote.isLocked(item)) throw new Error(_('Cannot display a locked note'));
|
||||
|
||||
let content = '';
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
|
||||
const Command = require('./command-edit');
|
||||
|
||||
describe('command-edit', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setupApplication();
|
||||
});
|
||||
|
||||
it('should refuse to edit a locked note without writing a temp file', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
|
||||
const tempDir = Setting.value('tempDir');
|
||||
await fs.ensureDir(tempDir);
|
||||
const filesBefore = await fs.readdir(tempDir);
|
||||
|
||||
const command = setupCommandForTesting(Command);
|
||||
|
||||
await expect(command.action({ note: note.id })).rejects.toThrow('locked note');
|
||||
expect(await fs.readdir(tempDir)).toEqual(filesBefore);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
|
||||
const Command = require('./command-set');
|
||||
|
||||
describe('command-set', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setupApplication();
|
||||
});
|
||||
|
||||
// This guard lives in BaseCommand.encryptionCheck, so it also covers attach, done, edit, ren and todo.
|
||||
it.each([
|
||||
{ label: 'refuse to change a locked note', flagEnabled: true, expectedError: 'Cannot change a locked note', expectedTitle: 'hello' },
|
||||
{ label: 'change a locked note when note lock is disabled', flagEnabled: false, expectedError: null, expectedTitle: 'renamed' },
|
||||
])('should $label', async ({ flagEnabled, expectedError, expectedTitle }) => {
|
||||
Setting.setValue('featureFlag.noteLock', flagEnabled);
|
||||
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
|
||||
|
||||
const command = setupCommandForTesting(Command);
|
||||
let error: string|null = null;
|
||||
try {
|
||||
await command.action({ note: note.id, name: 'title', value: 'renamed' });
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
|
||||
expect(error).toBe(expectedError);
|
||||
expect((await Note.load(note.id)).title).toBe(expectedTitle);
|
||||
});
|
||||
|
||||
it('should block changing the lock state of a plain note', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const note = await Note.save({ title: 'hello', body: 'plain', parent_id: '' });
|
||||
|
||||
const command = setupCommandForTesting(Command);
|
||||
await expect(command.action({ note: note.id, name: 'is_locked', value: '1' })).rejects.toThrow('The note lock state cannot be changed from the command line');
|
||||
expect((await Note.load(note.id)).is_locked).toBe(0);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { _ } from '@joplin/lib/locale';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Database from '@joplin/lib/database';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
@@ -35,11 +34,6 @@ class Command extends BaseCommand {
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
this.encryptionCheck(notes[i]);
|
||||
|
||||
// Enabling the lock here would only fail deep inside the gated save, so refuse it outright.
|
||||
if (isNoteLockEnabled() && propName === 'is_locked') {
|
||||
throw new Error(_('The note lock state cannot be changed from the command line'));
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
|
||||
const newNote: Record<string, unknown> = {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "3.7.1",
|
||||
"version": "3.7.0",
|
||||
"bin": "./main.js",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -48,7 +48,7 @@
|
||||
"chalk": "4.1.2",
|
||||
"compare-version": "0.1.2",
|
||||
"file-type": "16.5.4",
|
||||
"fs-extra": "11.3.5",
|
||||
"fs-extra": "11.3.4",
|
||||
"html-entities": "1.4.0",
|
||||
"keytar": "7.9.0",
|
||||
"md5": "2.3.0",
|
||||
@@ -62,7 +62,7 @@
|
||||
"sqlite3": "5.1.6",
|
||||
"string-padding": "1.0.2",
|
||||
"strip-ansi": "6.0.1",
|
||||
"tcp-port-used": "1.0.3",
|
||||
"tcp-port-used": "1.0.2",
|
||||
"terminal-kit": "3.1.2",
|
||||
"tkwidgets": "0.5.27",
|
||||
"url-parse": "1.5.10",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
|
||||
<en-export export-date="20260805T200517Z" application="Evernote" version="11.28.2">
|
||||
<note>
|
||||
<title>Note 2</title>
|
||||
<created>20260805T185318Z</created>
|
||||
<updated>20260805T200333Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>This is a test.</div><div><br/></div><div><span style="--en-highlight:red;background-color: #ffe2d5;">Link to a note in another notebook</span>: <a type="inline-richlink" rev="{"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>
|
||||
@@ -1,40 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
|
||||
<en-export export-date="20260805T200508Z" application="Evernote" version="11.28.2">
|
||||
<note>
|
||||
<title>Test</title>
|
||||
<created>20260805T184526Z</created>
|
||||
<updated>20260805T200439Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div><br/></div><div><a type="inline-richlink" rev="{"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>test</a>
|
||||
<a data-from-md href='#'>test</a>
|
||||
@@ -146,14 +146,13 @@ describe('app.reducer', () => {
|
||||
toolCallId: 'call-1',
|
||||
userDescription: '',
|
||||
isError: false,
|
||||
isEdit: true,
|
||||
content: 'Result',
|
||||
} satisfies ChatToolMessage),
|
||||
});
|
||||
|
||||
expect(state.aiChatMessages).toMatchObject([
|
||||
{ id: 'id-0', role: 'user', text: 'Test', raw: [{ role: 'user' }] },
|
||||
{ id: 'id-1', role: 'assistant', text: 'Testing' },
|
||||
{ id: 'id-1', role: 'assistant', text: 'Testing', editsApplied: 1, editsMissed: 0 },
|
||||
]);
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ export interface AiChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'error' | 'separator';
|
||||
text: string;
|
||||
hide?: boolean;
|
||||
editsApplied?: number;
|
||||
editsMissed?: number;
|
||||
|
||||
// The raw message(s) corresponding to this event
|
||||
raw: ChatMessage[];
|
||||
@@ -316,8 +317,15 @@ export default function(state: AppState, action: any) {
|
||||
state, action.windowId, 'aiChatMessages', messages => {
|
||||
let lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage) {
|
||||
const toolCall = action.toolCall;
|
||||
const error = toolCall.isError;
|
||||
const editsApplied = (lastMessage.editsApplied ?? 0) + (error ? 0 : 1);
|
||||
const editsMissed = (lastMessage.editsMissed ?? 0) + (error ? 1 : 0);
|
||||
|
||||
lastMessage = {
|
||||
...lastMessage,
|
||||
editsApplied,
|
||||
editsMissed,
|
||||
raw: [
|
||||
...lastMessage.raw,
|
||||
action.toolCall,
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import BaseApplication from '@joplin/lib/BaseApplication';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
import { setEncryptionEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { loadEncryptionMasterKey, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { TextEncoder as NodeTextEncoder } from 'util';
|
||||
import app from './app';
|
||||
|
||||
type NoteUpdateAction = {
|
||||
changeSource: number;
|
||||
changedFields: string[];
|
||||
note: NoteEntity;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const mockBaseMiddleware = () => {
|
||||
return jest.spyOn(BaseApplication.prototype as unknown as {
|
||||
generalMiddleware: (store: unknown, next: (action: unknown)=> unknown, action: unknown)=> Promise<unknown>;
|
||||
}, 'generalMiddleware').mockImplementation(async (_store, next, action) => next(action));
|
||||
};
|
||||
|
||||
const createDesktopActionHandler = () => {
|
||||
const middleware = app().generalMiddlewareFn()({ getState: jest.fn() });
|
||||
return middleware(jest.fn());
|
||||
};
|
||||
|
||||
describe('app', () => {
|
||||
const watcher = ExternalEditWatcher.instance();
|
||||
const originalTextEncoder = globalThis.TextEncoder;
|
||||
const originalNoteDispatch = Note.dispatch;
|
||||
let baseMiddlewareMock: jest.SpyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
baseMiddlewareMock = mockBaseMiddleware();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
Note.dispatch = originalNoteDispatch;
|
||||
baseMiddlewareMock.mockRestore();
|
||||
setEncryptionEnabled(false);
|
||||
Object.defineProperty(globalThis, 'TextEncoder', { configurable: true, value: originalTextEncoder });
|
||||
await watcher.stopWatchingAll();
|
||||
});
|
||||
|
||||
const startExternalEditing = async () => {
|
||||
const openItem = jest.fn();
|
||||
watcher.initialize(jest.fn(() => ({
|
||||
openItem,
|
||||
})), jest.fn());
|
||||
|
||||
const originalNote = await Note.save({
|
||||
title: 'Test note',
|
||||
body: 'Original body',
|
||||
});
|
||||
await watcher.openAndWatch(originalNote);
|
||||
|
||||
return {
|
||||
originalNote,
|
||||
externalFilePath: openItem.mock.calls[0][0] as string,
|
||||
};
|
||||
};
|
||||
|
||||
test('should update the external edit file after a synced note changes', async () => {
|
||||
const { originalNote, externalFilePath } = await startExternalEditing();
|
||||
const syncedNote = await Note.save({
|
||||
...originalNote,
|
||||
body: 'Changed on another device',
|
||||
}, { changeSource: ItemChange.SOURCE_SYNC });
|
||||
const syncedUpdateAction = {
|
||||
type: 'NOTE_UPDATE_ONE',
|
||||
changeSource: ItemChange.SOURCE_SYNC,
|
||||
changedFields: ['body'],
|
||||
note: syncedNote,
|
||||
};
|
||||
|
||||
const handleDesktopAction = createDesktopActionHandler();
|
||||
await handleDesktopAction(syncedUpdateAction);
|
||||
|
||||
expect(await readFile(externalFilePath, 'utf8')).toBe(await Note.serializeForEdit(syncedNote));
|
||||
});
|
||||
|
||||
test('should not update the external edit file when the note is not watched', async () => {
|
||||
const note = await Note.save({
|
||||
title: 'Test note',
|
||||
body: 'Changed on another device',
|
||||
}, { changeSource: ItemChange.SOURCE_SYNC });
|
||||
const updateNoteFile = jest.spyOn(watcher, 'updateNoteFile');
|
||||
|
||||
const handleDesktopAction = createDesktopActionHandler();
|
||||
await handleDesktopAction({
|
||||
type: 'NOTE_UPDATE_ONE',
|
||||
changeSource: ItemChange.SOURCE_SYNC,
|
||||
changedFields: ['body'],
|
||||
note,
|
||||
});
|
||||
|
||||
expect(watcher.noteIsWatched(note)).toBe(false);
|
||||
expect(updateNoteFile).not.toHaveBeenCalled();
|
||||
updateNoteFile.mockRestore();
|
||||
});
|
||||
|
||||
test('should not update the external edit file when the synced note is locked', async () => {
|
||||
const { originalNote } = await startExternalEditing();
|
||||
const updateNoteFile = jest.spyOn(watcher, 'updateNoteFile');
|
||||
|
||||
const handleDesktopAction = createDesktopActionHandler();
|
||||
await handleDesktopAction({
|
||||
type: 'NOTE_UPDATE_ONE',
|
||||
changeSource: ItemChange.SOURCE_SYNC,
|
||||
changedFields: ['body', 'is_locked'],
|
||||
note: { ...originalNote, body: 'Locked note ciphertext', is_locked: 1 },
|
||||
});
|
||||
|
||||
expect(updateNoteFile).not.toHaveBeenCalled();
|
||||
updateNoteFile.mockRestore();
|
||||
});
|
||||
|
||||
test('should wait for a synced encrypted note to be decrypted before updating the external edit file', async () => {
|
||||
const { originalNote, externalFilePath } = await startExternalEditing();
|
||||
const noteActions = jest.fn();
|
||||
Note.dispatch = noteActions;
|
||||
|
||||
Object.defineProperty(globalThis, 'TextEncoder', { configurable: true, value: NodeTextEncoder });
|
||||
setEncryptionEnabled(true);
|
||||
await loadEncryptionMasterKey();
|
||||
|
||||
const encryptedSyncPayload = await Note.serializeForSync({
|
||||
...originalNote,
|
||||
body: 'Changed on another device',
|
||||
} as NoteEntity);
|
||||
const encryptedNote = Note.filter(await Note.unserialize(encryptedSyncPayload));
|
||||
const savedEncryptedNote = await Note.save(encryptedNote, {
|
||||
autoTimestamp: false,
|
||||
changeSource: ItemChange.SOURCE_SYNC,
|
||||
oldItem: originalNote as unknown as Record<string, unknown>,
|
||||
});
|
||||
const encryptedSyncAction = noteActions.mock.calls[0][0] as NoteUpdateAction;
|
||||
|
||||
expect(encryptedSyncAction.note.encryption_applied).toBe(1);
|
||||
|
||||
const handleDesktopAction = createDesktopActionHandler();
|
||||
await handleDesktopAction(encryptedSyncAction);
|
||||
|
||||
expect(await readFile(externalFilePath, 'utf8')).toBe(await Note.serializeForEdit(originalNote));
|
||||
|
||||
noteActions.mockClear();
|
||||
const decryptedNote = await Note.decrypt(savedEncryptedNote);
|
||||
const decryptedUpdateActions = noteActions.mock.calls
|
||||
.map(call => call[0] as NoteUpdateAction)
|
||||
.filter(action => action.type === 'NOTE_UPDATE_ONE');
|
||||
expect(decryptedUpdateActions).toHaveLength(1);
|
||||
const decryptedAction = decryptedUpdateActions[0];
|
||||
|
||||
expect(decryptedAction.note).toEqual(decryptedNote);
|
||||
expect(decryptedAction.note.body).toBe('Changed on another device');
|
||||
expect(decryptedAction.note.encryption_applied).toBe(0);
|
||||
expect(decryptedAction.changedFields).toEqual(expect.arrayContaining(['body', 'encryption_applied']));
|
||||
|
||||
await handleDesktopAction(decryptedAction);
|
||||
|
||||
expect(await readFile(externalFilePath, 'utf8')).toBe(await Note.serializeForEdit(decryptedNote));
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,6 @@ import Resource from '@joplin/lib/models/Resource';
|
||||
import AiService from '@joplin/lib/services/ai/AiService';
|
||||
import LocalEmbeddingProvider from '@joplin/lib/services/ai/LocalEmbeddingProvider';
|
||||
import { installAiStatusBridge, AiStatusStore } from './services/aiStatusBridge';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
|
||||
const perfLogger = PerformanceLogger.create();
|
||||
|
||||
@@ -163,10 +162,6 @@ class Application extends BaseApplication {
|
||||
await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE');
|
||||
}
|
||||
|
||||
if (action.type === 'NOTE_DELETE' && store.getState().watchedNoteFiles.includes(action.id)) {
|
||||
await ExternalEditWatcher.instance().stopWatching(action.id);
|
||||
}
|
||||
|
||||
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'featureFlag.autoUpdaterServiceEnabled' || action.type === 'SETTING_UPDATE_ALL') {
|
||||
if (Setting.value('featureFlag.autoUpdaterServiceEnabled')) this.setupAutoUpdaterService();
|
||||
}
|
||||
@@ -174,19 +169,6 @@ class Application extends BaseApplication {
|
||||
const result = await super.generalMiddleware(store, next, action);
|
||||
const newState = store.getState();
|
||||
|
||||
if (
|
||||
action.type === 'NOTE_UPDATE_ONE' &&
|
||||
[ItemChange.SOURCE_SYNC, ItemChange.SOURCE_DECRYPTION].includes(action.changeSource) &&
|
||||
!action.note.encryption_applied &&
|
||||
!action.note.is_locked &&
|
||||
action.changedFields.some((field: string) => ['title', 'body'].includes(field))
|
||||
) {
|
||||
const externalEditWatcher = ExternalEditWatcher.instance();
|
||||
if (externalEditWatcher.noteIsWatched(action.note)) {
|
||||
await externalEditWatcher.updateNoteFile(action.note);
|
||||
}
|
||||
}
|
||||
|
||||
if (['NOTE_VISIBLE_PANES_TOGGLE', 'NOTE_VISIBLE_PANES_SET'].indexOf(action.type) >= 0) {
|
||||
Setting.setValue('noteVisiblePanes', newState.noteVisiblePanes);
|
||||
}
|
||||
@@ -790,15 +772,6 @@ class Application extends BaseApplication {
|
||||
});
|
||||
});
|
||||
|
||||
addTask('app/listen for note lock session events', () => {
|
||||
eventManager.on(EventName.NoteLockSessionChange, (event) => {
|
||||
this.dispatch({
|
||||
type: 'SET_NOTE_LOCK_SESSION_UNLOCKED',
|
||||
value: event.unlocked,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
addTask('app/setupOcrService', () => this.setupOcrService());
|
||||
|
||||
return tasks;
|
||||
|
||||
@@ -2,11 +2,8 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { createAppDefaultWindowState } from '../app.reducer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { getDisplayParentId } from '@joplin/lib/services/trash';
|
||||
import { ALL_NOTES_FILTER_ID } from '@joplin/lib/reserved-ids';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'openNoteInNewWindow',
|
||||
@@ -21,14 +18,11 @@ export const runtime = (): CommandRuntime => {
|
||||
execute: async (context: CommandContext, noteId: string = null) => {
|
||||
noteId = noteId || stateUtils.selectedNoteId(context.state);
|
||||
|
||||
const note = await Note.load(noteId, { fields: Note.previewFields() });
|
||||
const parentFolder = await Folder.load(note.parent_id);
|
||||
let folderId = note.is_conflict && !note.deleted_time ? Folder.conflictFolderId() : getDisplayParentId(note, parentFolder);
|
||||
if (!note.is_conflict && !note.deleted_time && !parentFolder) folderId = ALL_NOTES_FILTER_ID;
|
||||
const note = await Note.load(noteId, { fields: ['parent_id'] });
|
||||
context.dispatch({
|
||||
type: 'WINDOW_OPEN',
|
||||
noteId,
|
||||
folderId,
|
||||
folderId: note.parent_id,
|
||||
windowId: `window-${noteId}-${idCounter++}`,
|
||||
defaultAppWindowState: {
|
||||
...createAppDefaultWindowState(),
|
||||
|
||||
@@ -3,7 +3,6 @@ import { _ } from '@joplin/lib/locale';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import bridge from '../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
@@ -19,14 +18,11 @@ export const runtime = (): CommandRuntime => {
|
||||
|
||||
try {
|
||||
const note = await Note.load(noteId);
|
||||
// The enabled condition can be bypassed when the command is executed directly (e.g. via
|
||||
// toggleExternalEditing): the external editor would get ciphertext, or write plaintext to disk.
|
||||
if (isNoteLockEnabled() && note.is_locked) throw new Error(_('Locked notes cannot be opened in an external editor'));
|
||||
void ExternalEditWatcher.instance().openAndWatch(note);
|
||||
} catch (error) {
|
||||
bridge().showErrorMessageBox(_('Error opening note in editor: %s', error.message));
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && !noteIsLocked',
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly',
|
||||
};
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export const runtime = (): CommandRuntime => {
|
||||
void CommandService.instance().execute('startExternalEditing', noteId);
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && !noteIsLocked && (!modalDialogVisible || gotoAnythingVisible)',
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && (!modalDialogVisible || gotoAnythingVisible)',
|
||||
mapStateToTitle: (state: AppState) => {
|
||||
const noteId = stateUtils.selectedNoteId(state);
|
||||
return state.watchedNoteFiles.includes(noteId) ? _('Stop') : '';
|
||||
|
||||
@@ -2,45 +2,17 @@ import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { AiChatMessage } from '../../app.reducer';
|
||||
import InlineMarkdownDisplay from '../InlineMarkdownDisplay';
|
||||
import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/types';
|
||||
import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types';
|
||||
|
||||
|
||||
const toolResultSummary = (actions: ChatMessage[]) => {
|
||||
const toolResults = actions.filter(action => action.role === ChatRole.Tool);
|
||||
|
||||
const editSummary = () => {
|
||||
let applied = 0;
|
||||
let missed = 0;
|
||||
let lastEdit: ChatToolMessage|null = null;
|
||||
for (const result of toolResults) {
|
||||
if (!result.isEdit) continue;
|
||||
|
||||
if (result.isError) {
|
||||
missed ++;
|
||||
} else {
|
||||
applied ++;
|
||||
lastEdit = result;
|
||||
}
|
||||
}
|
||||
|
||||
if (applied + missed === 0) return '';
|
||||
if (missed === 0 && applied > 1) return _('%d edit(s) applied.', applied);
|
||||
if (missed === 0 && lastEdit) return lastEdit.userDescription;
|
||||
return _('%d edit(s) applied, %d could not be placed automatically.', applied, missed);
|
||||
};
|
||||
|
||||
return [
|
||||
...toolResults
|
||||
.filter(result => !result.isEdit)
|
||||
.map(result => result.userDescription),
|
||||
editSummary(),
|
||||
]
|
||||
.filter(item => !!item)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const hasToolError = (actions: ChatMessage[]) => {
|
||||
return actions.some(action => action.role === ChatRole.Tool && action.isError);
|
||||
const editsSummary = (actions: ChatMessage[], applied: number, missed: number) => {
|
||||
if (applied + missed === 0) return '';
|
||||
if (missed === 0 && applied > 1) return _('%d edit(s) applied.', applied);
|
||||
if (missed === 0) {
|
||||
const toolResults = actions.filter(action => action.role === ChatRole.Tool);
|
||||
return toolResults.map(result => result.userDescription).join('\n');
|
||||
}
|
||||
return _('%d edit(s) applied, %d could not be placed automatically.', applied, missed);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -55,7 +27,7 @@ const ChatMessageItem: React.FC<Props> = ({ message }) => {
|
||||
return <div className='error'>{message.text}</div>;
|
||||
}
|
||||
|
||||
const summary = message.role === 'assistant' ? toolResultSummary(message.raw) : '';
|
||||
const summary = message.role === 'assistant' ? editsSummary(message.raw, message.editsApplied ?? 0, message.editsMissed ?? 0) : '';
|
||||
// Always show something in the message box:
|
||||
const textContent = !message.text && !summary ? _('(no message)') : message.text;
|
||||
|
||||
@@ -72,7 +44,7 @@ const ChatMessageItem: React.FC<Props> = ({ message }) => {
|
||||
{content}
|
||||
{summary && (
|
||||
<div className='meta'>
|
||||
{hasToolError(message.raw)
|
||||
{(message.editsMissed ?? 0) > 0
|
||||
? <span className='warning'>{summary}</span>
|
||||
: <span>{summary}</span>}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ import JoplinError from '@joplin/lib/JoplinError';
|
||||
import eventManager, { EventName, ItemChangeEvent } from '@joplin/lib/eventManager';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import ChatMessageItem from './ChatMessageItem';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
|
||||
const logger = Logger.create('ChatPanel');
|
||||
|
||||
@@ -143,13 +142,11 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
lastNoteIdRef.current = props.noteId;
|
||||
if (prev === null || prev === props.noteId || !props.noteId) return;
|
||||
if (messagesLengthRef.current === 0) return;
|
||||
|
||||
const text = _('— now viewing: %s —', props.noteTitle || _('(untitled)'));
|
||||
appendMessage({
|
||||
id: makeId(),
|
||||
role: 'separator',
|
||||
text,
|
||||
raw: [{ role: ChatRole.User, content: _('Switched notes: %s', text) }],
|
||||
text: _('— now viewing: %s —', props.noteTitle || _('(untitled)')),
|
||||
raw: [],
|
||||
});
|
||||
}, [props.noteId, props.noteTitle, appendMessage]);
|
||||
|
||||
@@ -191,7 +188,7 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
// Captured so we can roll it back on failure — otherwise a retry would
|
||||
// send the prior user turn as history alongside the new prompt.
|
||||
const userTurnId = makeId();
|
||||
let hadVisibleResponse = false;
|
||||
let hadSuccessfulResponse = false;
|
||||
appendMessage({
|
||||
id: userTurnId,
|
||||
role: 'user',
|
||||
@@ -220,8 +217,6 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
body: note.body,
|
||||
title: note.title,
|
||||
selection,
|
||||
noteId: note.id,
|
||||
folderId: note.parent_id,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -241,16 +236,15 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
// System messages are not shown in the UI
|
||||
if (entry.role === ChatRole.System) continue;
|
||||
|
||||
hadSuccessfulResponse = true;
|
||||
|
||||
if (entry.role === ChatRole.Tool) {
|
||||
addToolResult(entry);
|
||||
} else {
|
||||
hadVisibleResponse ||= !entry.hide;
|
||||
|
||||
appendMessage({
|
||||
id: makeId(),
|
||||
role: entry.role,
|
||||
text: entry.content,
|
||||
hide: entry.hide,
|
||||
raw: [entry],
|
||||
});
|
||||
}
|
||||
@@ -301,7 +295,7 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
logger.warn('Chat failed:', error);
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
if (!hadVisibleResponse) {
|
||||
if (!hadSuccessfulResponse) {
|
||||
dispatch({ type: 'AI_CHAT_REMOVE', windowId, id: userTurnId });
|
||||
setInput(text);
|
||||
}
|
||||
@@ -322,10 +316,6 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
dispatch({ type: 'AI_CHAT_RESET', windowId: windowId });
|
||||
}, [dispatch, windowId, cancelRequest]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void CommandService.instance().executeInWindow('toggleAiChat', { windowId: windowId, args: [] });
|
||||
}, [windowId]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Don't send while an IME composition is in flight — Enter commits
|
||||
// the composition for CJK / accented input.
|
||||
@@ -353,24 +343,15 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
return { content, showingMessages: false };
|
||||
}
|
||||
|
||||
const visibleMessages = messages.filter(m => !m.hide);
|
||||
|
||||
const content = <>
|
||||
{props.aiDegraded && <AiDegradedNotice className='degraded-status' />}
|
||||
<div className='messages' aria-live={hasFocus ? 'polite' : undefined}>
|
||||
{visibleMessages.length === 0 && (
|
||||
{messages.length === 0 && (
|
||||
<div className='empty'>
|
||||
{_('Ask about this note, or request changes. Select text in the editor first to scope the request to that selection.')}
|
||||
<br/>
|
||||
<br/>
|
||||
<button
|
||||
type='button'
|
||||
className='link-button'
|
||||
onClick={() => NavService.go('Config', { props: { defaultSection: 'ai.tools' } })}
|
||||
>{_('Manage capabilities')}</button>
|
||||
</div>
|
||||
)}
|
||||
{visibleMessages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
|
||||
{messages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className='composer'>
|
||||
@@ -404,28 +385,13 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</>;
|
||||
|
||||
return { content, showingMessages: visibleMessages.length > 0 };
|
||||
return { content, showingMessages: messages.length > 0 };
|
||||
};
|
||||
|
||||
const sendButtonLabel = sending ? _('Stop generating') : _('Send');
|
||||
const headerId = useId();
|
||||
const { content, showingMessages } = renderContent();
|
||||
|
||||
const renderHeaderActions = () => {
|
||||
if (showingMessages) {
|
||||
return <button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>;
|
||||
}
|
||||
|
||||
const closeLabel = _('Close');
|
||||
return <button
|
||||
type='button'
|
||||
className='close'
|
||||
onClick={handleClose}
|
||||
title={closeLabel}
|
||||
aria-label={closeLabel}
|
||||
><i className='fas fa-times' role='img' aria-hidden={true}/></button>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='chat-panel'
|
||||
@@ -434,9 +400,11 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<div className='header chat-panel-header'>
|
||||
<div className='header'>
|
||||
<h1 className='title' id={headerId}>{_('AI Chat')}</h1>
|
||||
{renderHeaderActions()}
|
||||
{showingMessages && (
|
||||
<button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>
|
||||
)}
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
@@ -19,25 +19,23 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-panel-header {
|
||||
> .title {
|
||||
flex: 1;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
.chat-panel > .header > .title {
|
||||
flex: 1;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> .reset, > .close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--joplin-color-faded);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
padding: 2px 6px;
|
||||
.chat-panel > .header > .reset {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--joplin-color-faded);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
}
|
||||
.chat-panel > .header > .reset:hover {
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
|
||||
.chat-panel > .degraded-status {
|
||||
|
||||
@@ -124,10 +124,6 @@ const NoteLockSettings: React.FC<Props> = props => {
|
||||
|
||||
const resetPasswordTitle = `⚠️ ${_('Reset password')} ⚠️`;
|
||||
const actionButtonTitle = mode === ActionMode.Reset ? resetPasswordTitle : _('Save');
|
||||
// The sync advice only applies to a first setup, where there may be nothing to lose yet.
|
||||
const passwordReminder = mode === ActionMode.Create ?
|
||||
_('Please make sure you remember your password. It cannot be recovered if lost, and any locked notes will become inaccessible. If you have already set up the note lock password on another device, there is no need to set it again, as the information required to access your locked notes is updated when you set up sync.') :
|
||||
_('Please make sure you remember your password. It cannot be recovered if lost, and any locked notes will become inaccessible.');
|
||||
|
||||
const getSectionTitle = () => {
|
||||
if (mode === ActionMode.Reset) return resetPasswordTitle;
|
||||
@@ -201,7 +197,7 @@ const NoteLockSettings: React.FC<Props> = props => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className='reminder'><strong>{passwordReminder}</strong></p>
|
||||
<p className='reminder'><strong>{_('Please make sure you remember your password. It cannot be recovered if lost, and any data encrypted with it will become inaccessible.')}</strong></p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -212,7 +208,7 @@ const NoteLockSettings: React.FC<Props> = props => {
|
||||
return (
|
||||
<div className='section'>
|
||||
<h2>{_('Key upgrade')}</h2>
|
||||
<p>{_('For better security, your note lock protection can be upgraded to a newer method. Enter your password to upgrade it. Your notes will keep locking and unlocking as usual.')}</p>
|
||||
<p>{_('The note lock key uses an out-dated encryption algorithm and it is recommended to upgrade it. The upgraded key will still be able to decrypt and encrypt your data as usual.')}</p>
|
||||
<div className='form'>
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Password')}
|
||||
|
||||
@@ -7,7 +7,7 @@ import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
import dialogs from '../dialogs';
|
||||
import { decryptedStatText, determineKeyPassword, dontReencryptData, enableEncryptionConfirmationMessages, onSavePasswordClick, onToggleEnabledClick, reencryptData, upgradeMasterKey, useInputPasswords, useNeedMasterPassword, usePasswordChecker, useStats, useToggleShowDisabledMasterKeys } from '@joplin/lib/components/EncryptionConfigScreen/utils';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
import { getEncryptionEnabled, localSyncInfoSelector, masterKeyEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { getDefaultMasterKey, getMasterPasswordStatusMessage, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
@@ -606,7 +606,7 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const syncInfo = localSyncInfoSelector(state);
|
||||
const syncInfo = new SyncInfo(state.settings['syncInfoCache']);
|
||||
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
|
||||
@@ -27,8 +27,7 @@ import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
|
||||
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
|
||||
import removeKeylessItems from './ResizableLayout/utils/removeKeylessItems';
|
||||
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import executeCallbackUrl from './MainScreen/handleCallbackUrl';
|
||||
import { isCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import ElectronAppWrapper from '../ElectronAppWrapper';
|
||||
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
@@ -39,6 +38,7 @@ import TrashNotification from './TrashNotification/TrashNotification';
|
||||
import UpdateNotification from './UpdateNotification/UpdateNotification';
|
||||
import PluginNotification from './PluginNotification/PluginNotification';
|
||||
import { Toast } from '@joplin/lib/services/plugins/api/types';
|
||||
import QuitSyncDialog from './QuitSyncDialog';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import checkForUpdates, { isReleaseVersion } from '../checkForUpdates';
|
||||
|
||||
@@ -153,23 +153,20 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
||||
ipcRenderer.on('asynchronous-message', (_event: import('electron').IpcRendererEvent, message: string, args: { url: string }) => {
|
||||
if (message === 'openCallbackUrl') {
|
||||
void this.openCallbackUrl(args.url);
|
||||
this.openCallbackUrl(args.url);
|
||||
}
|
||||
});
|
||||
|
||||
const initialCallbackUrl = (bridge().electronApp() as ElectronAppWrapper).initialCallbackUrl();
|
||||
if (initialCallbackUrl) {
|
||||
void this.openCallbackUrl(initialCallbackUrl);
|
||||
this.openCallbackUrl(initialCallbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async openCallbackUrl(url: string) {
|
||||
try {
|
||||
if (!isCallbackUrl(url)) throw new Error(`Invalid callback URL: ${url}`);
|
||||
await executeCallbackUrl(url);
|
||||
} catch (error) {
|
||||
logger.error('Error handling callback URL:', error);
|
||||
}
|
||||
private openCallbackUrl(url: string) {
|
||||
if (!isCallbackUrl(url)) throw new Error(`Invalid callback URL: ${url}`);
|
||||
const { command, params } = parseCallbackUrl(url);
|
||||
void CommandService.instance().execute(command.toString(), params.id);
|
||||
}
|
||||
|
||||
private updateLayoutPluginViews(layout: LayoutItem, plugins: PluginStates) {
|
||||
@@ -185,7 +182,6 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
if (!existingItem) {
|
||||
draftLayout.children.push({
|
||||
key: viewId,
|
||||
visible: info.view.opened,
|
||||
context: {
|
||||
pluginId: info.plugin.id,
|
||||
},
|
||||
@@ -802,6 +798,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
themeId={this.props.themeId}
|
||||
toast={this.props.toast}
|
||||
/>
|
||||
<QuitSyncDialog themeId={this.props.themeId} />
|
||||
{messageComp}
|
||||
{layoutComp}
|
||||
</div>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { utils as commandUtils } from '@joplin/lib/services/CommandService';
|
||||
import executeCallbackUrl from './handleCallbackUrl';
|
||||
import { withWarningSilenced } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
const mockOpenExternal = jest.fn();
|
||||
|
||||
jest.mock('../../services/bridge', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
openExternal: (url: string) => mockOpenExternal(url),
|
||||
}),
|
||||
}));
|
||||
|
||||
const setSelectedNoteId = (noteId: string | null) => {
|
||||
// executeCallbackUrl reads the selected note id via commandUtils.store; a
|
||||
// null id makes handleGetCurrentNote take its x-error branch, which routes
|
||||
// the caller-supplied target through respond() without touching the DB.
|
||||
commandUtils.store = {
|
||||
getState: () => ({ selectedNoteIds: noteId ? [noteId] : [] }),
|
||||
};
|
||||
};
|
||||
|
||||
describe('handleCallbackUrl', () => {
|
||||
beforeEach(() => {
|
||||
mockOpenExternal.mockClear();
|
||||
setSelectedNoteId(null);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'x-custom-scheme://arbitrary-scheme-reached',
|
||||
'file:///etc/passwd',
|
||||
'ms-msdt://something',
|
||||
'mailto:someone@example.com',
|
||||
'not a url',
|
||||
])('should not dispatch a callback target with a disallowed scheme (%s)', async (target) => {
|
||||
await withWarningSilenced(/Rejected malformed callback|Rejected callback target with disallowed scheme/, async () => {
|
||||
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
|
||||
}, { requireWarning: true });
|
||||
expect(mockOpenExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
'http://example.com/cb',
|
||||
'https://example.com/cb',
|
||||
])('should dispatch a callback target with an allowed scheme (%s)', async (target) => {
|
||||
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
|
||||
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenExternal.mock.calls[0][0]).toContain(`${target}?`);
|
||||
});
|
||||
|
||||
it('should not leak the raw error message on the error path', async () => {
|
||||
// Force a throw inside the try block so the catch path builds the response.
|
||||
commandUtils.store = {
|
||||
getState: () => { throw new Error('secret path /home/victim/.config/joplin/database.sqlite'); },
|
||||
};
|
||||
|
||||
await withWarningSilenced(/Error handling callback URL command "getCurrentNote":.*secret path \/home\/victim/, async () => {
|
||||
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent('https://example.com/cb')}`);
|
||||
}, { requireWarning: true });
|
||||
|
||||
expect(mockOpenExternal).toHaveBeenCalledTimes(1);
|
||||
const responseUrl = mockOpenExternal.mock.calls[0][0];
|
||||
expect(responseUrl).not.toContain('secret path');
|
||||
expect(responseUrl).not.toContain('database.sqlite');
|
||||
expect(new URL(responseUrl).searchParams.get('errorMessage')).toBe('The command could not be completed');
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import { CallbackUrlCommand, getNoteCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import CommandService, { utils as commandUtils } from '@joplin/lib/services/CommandService';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import bridge from '../../services/bridge';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('handleCallbackUrl');
|
||||
|
||||
// Opens the caller's x-success / x-error URL with the result appended as query params.
|
||||
const respond = (target: string, params: Record<string, string> = {}) => {
|
||||
if (!target) return;
|
||||
|
||||
// The callback target is untrusted input from the x-callback-url. Anything
|
||||
// that is not file: is handed straight to shell.openExternal(), i.e. the OS
|
||||
// URI dispatcher, so restrict it to schemes we're willing to invoke. This
|
||||
// also covers the x-error path below, which fires on any thrown exception.
|
||||
let protocol;
|
||||
try {
|
||||
protocol = new URL(target).protocol;
|
||||
} catch (error) {
|
||||
// Don't log the caller-supplied target: it's untrusted and may contain
|
||||
// secrets in its query or fragment.
|
||||
logger.warn('Rejected malformed callback target');
|
||||
return;
|
||||
}
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
logger.warn(`Rejected callback target with disallowed scheme "${protocol}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
|
||||
const fragmentIndex = target.indexOf('#');
|
||||
const base = fragmentIndex === -1 ? target : target.substring(0, fragmentIndex);
|
||||
const fragment = fragmentIndex === -1 ? '' : target.substring(fragmentIndex);
|
||||
const separator = base.includes('?') ? '&' : '?';
|
||||
const responseUrl = query ? `${base}${separator}${query}${fragment}` : target;
|
||||
void bridge().openExternal(responseUrl);
|
||||
};
|
||||
|
||||
const handleGetCurrentNote = async (params: Record<string, string>) => {
|
||||
const state = commandUtils.store.getState() as AppState;
|
||||
const noteId = stateUtils.selectedNoteId(state);
|
||||
if (!noteId) {
|
||||
respond(params['x-error'], { errorMessage: 'No note is currently selected' });
|
||||
return;
|
||||
}
|
||||
|
||||
const note = await Note.load(noteId);
|
||||
if (!note) {
|
||||
respond(params['x-error'], { errorMessage: 'The selected note could not be loaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
respond(params['x-success'], {
|
||||
title: note.title,
|
||||
url: getNoteCallbackUrl(note.id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateNote = async (params: Record<string, string>) => {
|
||||
const folder = await Folder.getValidActiveFolder();
|
||||
if (!folder) {
|
||||
respond(params['x-error'], { errorMessage: 'No valid notebook is available to create the note in' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Not provisional: an externally-requested note should persist even if unedited.
|
||||
const note = await Note.save({
|
||||
...Note.previewFieldsWithDefaultValues({ includeTimestamps: false }),
|
||||
title: params.title ?? '',
|
||||
body: params.body ?? '',
|
||||
parent_id: folder.id,
|
||||
});
|
||||
|
||||
commandUtils.store.dispatch({ type: 'NOTE_SELECT', id: note.id });
|
||||
|
||||
respond(params['x-success'], {
|
||||
title: note.title,
|
||||
url: getNoteCallbackUrl(note.id),
|
||||
});
|
||||
};
|
||||
|
||||
// Verbs dispatched directly to CommandService by name. Kept as an explicit
|
||||
// allowlist (not a catch-all) so a crafted URL can't run arbitrary commands. See 69826610a.
|
||||
const openCommands: string[] = [
|
||||
CallbackUrlCommand.OpenNote,
|
||||
CallbackUrlCommand.OpenFolder,
|
||||
CallbackUrlCommand.OpenTag,
|
||||
];
|
||||
|
||||
const executeCallbackUrl = async (url: string) => {
|
||||
const info = parseCallbackUrl(url);
|
||||
|
||||
if (!openCommands.includes(info.command) &&
|
||||
info.command !== CallbackUrlCommand.GetCurrentNote &&
|
||||
info.command !== CallbackUrlCommand.CreateNote) {
|
||||
throw new Error(`Unhandled callback URL command: ${info.command}`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (info.command === CallbackUrlCommand.GetCurrentNote) {
|
||||
await handleGetCurrentNote(info.params);
|
||||
} else if (info.command === CallbackUrlCommand.CreateNote) {
|
||||
await handleCreateNote(info.params);
|
||||
} else {
|
||||
await CommandService.instance().execute(info.command.toString(), info.params.id);
|
||||
logger.info(`Executed callback URL command: ${info.command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error handling callback URL command "${info.command}":`, error);
|
||||
// Return an opaque status rather than error.message, which can leak
|
||||
// profile paths and item ids to the caller-supplied x-error target.
|
||||
respond(info.params['x-error'], { errorMessage: 'The command could not be completed' });
|
||||
}
|
||||
};
|
||||
|
||||
export default executeCallbackUrl;
|
||||
@@ -13,7 +13,6 @@ import versionInfo, { PackageInfo } from '@joplin/lib/versionInfo';
|
||||
import { ImportModule } from '@joplin/lib/services/interop/Module';
|
||||
import InteropServiceHelper from '../InteropServiceHelper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import { isContextMenuItemLocation, MenuItem, MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
|
||||
import menuCommandNames from './menuCommandNames';
|
||||
@@ -865,19 +864,13 @@ 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,
|
||||
menuItemDic.revealInNotebook,
|
||||
separator(),
|
||||
menuItemDic.permanentlyDeleteNote,
|
||||
],
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
const logger = Logger.create('useEditorSearch');
|
||||
|
||||
@@ -15,8 +14,9 @@ interface SetMarkersOptions {
|
||||
showEditorMarkers?: boolean;
|
||||
withSelection?: boolean;
|
||||
}
|
||||
type Keyword = { value: string };
|
||||
|
||||
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: HighlightedWord[], options: SetMarkersOptions)=> number;
|
||||
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: Keyword[], options: SetMarkersOptions)=> number;
|
||||
|
||||
|
||||
// Modified from codemirror/addons/search/search.js
|
||||
@@ -109,11 +109,11 @@ export default function useEditorSearchExtension() {
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
|
||||
function escapeRegExp(keyword: string) {
|
||||
return keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
return keyword.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
|
||||
function getSearchTerm(keyword: HighlightedWord) {
|
||||
const value = escapeRegExp(typeof keyword === 'string' ? keyword : keyword.value);
|
||||
function getSearchTerm(keyword: Keyword) {
|
||||
const value = escapeRegExp(keyword.value);
|
||||
return new RegExp(value, 'gi');
|
||||
}
|
||||
|
||||
@@ -139,8 +139,6 @@ export default function useEditorSearchExtension() {
|
||||
|
||||
clearMarkers();
|
||||
|
||||
const keywordValue = (keyword: HighlightedWord) => typeof keyword === 'string' ? keyword : keyword.value;
|
||||
|
||||
// HIGHLIGHT KEYWORDS
|
||||
// When doing a global search it's possible to have multiple keywords
|
||||
// This means we need to highlight each one
|
||||
@@ -148,12 +146,12 @@ export default function useEditorSearchExtension() {
|
||||
for (let i = 0; i < keywords.length; i++) {
|
||||
const keyword = keywords[i];
|
||||
|
||||
if (keywordValue(keyword) === '') continue;
|
||||
if (keyword.value === '') continue;
|
||||
|
||||
const searchTerm = getSearchTerm(keyword);
|
||||
|
||||
// We only want to scroll the first keyword into view in the case of a multi keyword search
|
||||
const scrollTo = i === 0 && (previousKeywordValue !== keywordValue(keyword) || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
|
||||
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
|
||||
|
||||
try {
|
||||
const match = highlightSearch(cm, searchTerm, options.selectedIndex, scrollTo, !!options.withSelection);
|
||||
@@ -174,16 +172,15 @@ export default function useEditorSearchExtension() {
|
||||
setPreviousSearchTimestamp(options.searchTimestamp);
|
||||
|
||||
// SEARCHOVERLAY
|
||||
// We only want to highlight all matches when there is only 1 main search term
|
||||
const mainSearch = keywords.filter(keyword => typeof keyword === 'string' || keyword.source !== 'semantic');
|
||||
if (mainSearch.length !== 1 || keywordValue(mainSearch[0]) === '') {
|
||||
// We only want to highlight all matches when there is only 1 search term
|
||||
if (keywords.length !== 1 || keywords[0].value === '') {
|
||||
clearOverlay(cm);
|
||||
const prev = keywords.length > 1 ? keywordValue(keywords[0]) : '';
|
||||
const prev = keywords.length > 1 ? keywords[0].value : '';
|
||||
setPreviousKeywordValue(prev);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const searchTerm = getSearchTerm(mainSearch[0]);
|
||||
const searchTerm = getSearchTerm(keywords[0]);
|
||||
|
||||
// Determine the number of matches in the source, this is passed on
|
||||
// to the NoteEditor component
|
||||
@@ -192,10 +189,10 @@ export default function useEditorSearchExtension() {
|
||||
|
||||
// Don't bother clearing and re-calculating the overlay if the search term
|
||||
// hasn't changed
|
||||
if (keywordValue(keywords[0]) === previousKeywordValue) return nMatches;
|
||||
if (keywords[0].value === previousKeywordValue) return nMatches;
|
||||
|
||||
clearOverlay(cm);
|
||||
setPreviousKeywordValue(keywordValue(keywords[0]));
|
||||
setPreviousKeywordValue(keywords[0].value);
|
||||
|
||||
// These operations are pretty slow, so we won't add use them until the user
|
||||
// has finished typing, 500ms is probably enough time
|
||||
|
||||
@@ -710,14 +710,6 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [renderedBody, webviewReady]);
|
||||
|
||||
// Stop media playing in the viewer once it's hidden - https://github.com/laurent22/joplin/issues/15278
|
||||
const viewerVisible = props.visiblePanes.includes('viewer');
|
||||
useEffect(() => {
|
||||
if (!viewerVisible) {
|
||||
webviewRef.current?.stopMediaPlayback();
|
||||
}
|
||||
}, [viewerVisible]);
|
||||
|
||||
const { onSetInitialMarkersRef } = useEditorSearchHandler({
|
||||
setLocalSearchResultCount: props.setLocalSearchResultCount,
|
||||
searchMarkers: props.searchMarkers,
|
||||
|
||||
@@ -297,14 +297,6 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
|
||||
useRefocusOnVisiblePaneChange({ editorRef, webviewRef, visiblePanes: props.visiblePanes });
|
||||
|
||||
// Stop media playing in the viewer once it's hidden - https://github.com/laurent22/joplin/issues/15278
|
||||
const viewerVisible = props.visiblePanes.includes('viewer');
|
||||
useEffect(() => {
|
||||
if (!viewerVisible) {
|
||||
webviewRef.current?.stopMediaPlayback();
|
||||
}
|
||||
}, [viewerVisible]);
|
||||
|
||||
useEditorSearchHandler({
|
||||
setLocalSearchResultCount: props.setLocalSearchResultCount,
|
||||
searchMarkers: props.searchMarkers,
|
||||
|
||||
@@ -109,11 +109,7 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
|
||||
}
|
||||
|
||||
const searchState = editor.getSearchState();
|
||||
const externalSearchText = props.externalSearch
|
||||
.keywords
|
||||
.filter(k => typeof k === 'string' || k.source !== 'semantic')
|
||||
.map(k => typeof k === 'string' ? k : k.value)
|
||||
.join(' ') || searchState.searchText;
|
||||
const externalSearchText = props.externalSearch.keywords.map(k => k.value).join(' ') || searchState.searchText;
|
||||
|
||||
if (externalSearchText === searchState.searchText && searchState.dialogVisible === props.useLocalSearch) {
|
||||
return;
|
||||
|
||||
@@ -25,17 +25,4 @@ export default () => ({
|
||||
'replaced $ matches': _('replaced $ matches'),
|
||||
'replaced match on line $': _('replaced match on line $'),
|
||||
'on line': _('on line'),
|
||||
|
||||
// Table editor context menu. These keys must be kept in sync with the
|
||||
// state.phrase() calls in packages/editor renderTables.ts.
|
||||
'Insert row above': _('Insert row above'),
|
||||
'Insert row below': _('Insert row below'),
|
||||
'Insert column left': _('Insert column left'),
|
||||
'Insert column right': _('Insert column right'),
|
||||
'Move row up': _('Move row up'),
|
||||
'Move row down': _('Move row down'),
|
||||
'Move column left': _('Move column left'),
|
||||
'Move column right': _('Move column right'),
|
||||
'Delete row': _('Delete row'),
|
||||
'Delete column': _('Delete column'),
|
||||
});
|
||||
@@ -33,8 +33,7 @@ import { DropHandler } from '../../utils/useDropHandler';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import useWebViewApi from './utils/useWebViewApi';
|
||||
import useLinkTooltips from './utils/useLinkTooltips';
|
||||
import { blur, focus } from '@joplin/lib/utils/focusHandler';
|
||||
import useHighlightedSearchTerms from './utils/useHighlightedSearchTerms';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
const md5 = require('md5');
|
||||
import { clipboard } from 'electron';
|
||||
const supportedLocales = require('./supportedLocales');
|
||||
@@ -187,9 +186,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
if (!editorRef.current) return '';
|
||||
return prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editorRef.current.getContent(), props.contentOriginalCss);
|
||||
},
|
||||
blurEditor: () => {
|
||||
if (editor) blur('TinyMCE::reload', editor.getBody());
|
||||
},
|
||||
resetScroll: () => {
|
||||
if (editor) editor.getWin().scrollTo(0, 0);
|
||||
},
|
||||
@@ -1203,8 +1199,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
};
|
||||
}, [editor, onEditorContentClick]);
|
||||
|
||||
useHighlightedSearchTerms(editor, props.searchMarkers.keywords, props.themeId);
|
||||
|
||||
// This is to handle dropping notes on the editor. In this case, we add an
|
||||
// overlay over the editor, which makes it a valid drop target. This in
|
||||
// turn makes NoteEditor get the drop event and dispatch it.
|
||||
@@ -1249,8 +1243,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
// https://github.com/facebook/react/issues/14010#issuecomment-433788147
|
||||
const props_onChangeRef = useRef<NoteBodyEditorProps['onChange']>(null);
|
||||
props_onChangeRef.current = props.onChange;
|
||||
const editorNoteReloadTimeRequestRef = useRef(props.editorNoteReloadTimeRequest);
|
||||
editorNoteReloadTimeRequestRef.current = props.editorNoteReloadTimeRequest;
|
||||
|
||||
const prop_htmlToMarkdownRef = useRef<HtmlToMarkdownHandler>(null);
|
||||
prop_htmlToMarkdownRef.current = props.htmlToMarkdown;
|
||||
@@ -1263,14 +1255,12 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
if (!info) return;
|
||||
|
||||
nextOnChangeEventInfo.current = null;
|
||||
if (info.editorNoteReloadTimeRequest !== editorNoteReloadTimeRequestRef.current) return;
|
||||
|
||||
resetLinkTooltips();
|
||||
const contentMd = await prop_htmlToMarkdownRef.current(info.contentMarkupLanguage, info.editor.getContent(), info.contentOriginalCss);
|
||||
|
||||
lastOnChangeEventInfo.current.content = contentMd;
|
||||
lastOnChangeEventInfo.current.resourceInfos = await attachedResources(contentMd);
|
||||
if (info.editorNoteReloadTimeRequest !== editorNoteReloadTimeRequestRef.current) return;
|
||||
|
||||
props_onChangeRef.current({
|
||||
changeId: info.changeId,
|
||||
@@ -1311,7 +1301,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor: editor,
|
||||
contentMarkupLanguage: props.contentMarkupLanguage,
|
||||
contentOriginalCss: props.contentOriginalCss,
|
||||
editorNoteReloadTimeRequest: editorNoteReloadTimeRequestRef.current,
|
||||
};
|
||||
|
||||
onChangeHandlerTimeoutRef.current = shim.setTimeout(async () => {
|
||||
@@ -1558,8 +1547,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.on(TinyMceEditorEvents.JoplinChange, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.Undo, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.Redo, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.FormatApply, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.FormatRemove, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.ExecCommand, onExecCommand);
|
||||
editor.on(TinyMceEditorEvents.SetAttrib, onSetAttrib);
|
||||
editor.on('TableModified', onTableModified);
|
||||
@@ -1577,8 +1564,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.off(TinyMceEditorEvents.JoplinChange, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.Undo, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.Redo, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.FormatApply, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.FormatRemove, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.ExecCommand, onExecCommand);
|
||||
editor.off(TinyMceEditorEvents.SetAttrib, onSetAttrib);
|
||||
editor.off('TableModified', onTableModified);
|
||||
|
||||
@@ -15,8 +15,6 @@ export enum TinyMceEditorEvents {
|
||||
Redo = 'Redo',
|
||||
ExecCommand = 'ExecCommand',
|
||||
SetAttrib = 'SetAttrib',
|
||||
FormatApply = 'FormatApply',
|
||||
FormatRemove = 'FormatRemove',
|
||||
}
|
||||
|
||||
export type DispatchDidUpdateCallback = (editor: Editor)=> void;
|
||||
@@ -1,224 +0,0 @@
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Editor, EditorEvent } from 'tinymce';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Highlight: typeof Highlight;
|
||||
Range: typeof Range;
|
||||
CSS: typeof CSS;
|
||||
}
|
||||
}
|
||||
|
||||
const useSearchRegexes = (searchTerms: HighlightedWord[]) => {
|
||||
return useMemo(() => {
|
||||
return searchTerms.map((term: HighlightedWord) => {
|
||||
let text;
|
||||
if (typeof term === 'string') {
|
||||
text = term;
|
||||
} else {
|
||||
text = term.value;
|
||||
}
|
||||
|
||||
// Only highlight short matches at word boundaries (roughly matching the note viewer
|
||||
// behavior)
|
||||
const regexText = SearchEngine.instance().queryTermToRegex(text, { anchorToWordBoundary: text.length <= 2 });
|
||||
return regexText ? new RegExp(regexText, 'ig') : null;
|
||||
}).filter(regex => !!regex);
|
||||
}, [searchTerms]);
|
||||
};
|
||||
|
||||
const searchHighlightKey = 'jop-search-highlight';
|
||||
|
||||
const useHighlightStyleSheet = (editor: Editor, themeId: number) => {
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const theme: Theme = themeStyle(themeId);
|
||||
const style = editor.dom.create('style', {}, `
|
||||
/* Avoid showing highlights when the user has the find dialog open and
|
||||
there are TinyMCE-provided search results */
|
||||
body:not(:has(span.mce-match-marker)) {
|
||||
::highlight(${searchHighlightKey}) {
|
||||
background-color: ${theme.searchMarkerBackgroundColor};
|
||||
color: ${theme.searchMarkerColor};
|
||||
}
|
||||
}
|
||||
`);
|
||||
editor.getDoc().head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
style.remove();
|
||||
};
|
||||
}, [editor, themeId]);
|
||||
};
|
||||
|
||||
const useHighlighter = (editor: Editor, searchRegexes: RegExp[]) => {
|
||||
return useMemo(() => {
|
||||
if (!editor) {
|
||||
return { canHighlight: false };
|
||||
}
|
||||
|
||||
const editorWindow = editor.getWin();
|
||||
const ranges: Map<Node, Range[]> = new Map();
|
||||
let highlight: Highlight = undefined;
|
||||
|
||||
const processNode = (node: Node) => {
|
||||
ranges.delete(node);
|
||||
|
||||
type FoundChildAndOffset = { node: Node; offset: number };
|
||||
const findChildAtTextOffset = (parent: Node, offset: number): FoundChildAndOffset => {
|
||||
if (offset > parent.textContent.length) return null;
|
||||
if (parent.nodeName === '#text') {
|
||||
return { node: parent, offset };
|
||||
}
|
||||
|
||||
let start = 0;
|
||||
for (const child of parent.childNodes) {
|
||||
if (child.nodeName === '#comment') continue;
|
||||
|
||||
const found = findChildAtTextOffset(child, offset - start);
|
||||
start += child.textContent.length;
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildRange = (parent: Node, startIndex: number, endIndex: number) => {
|
||||
const range: Range = new editorWindow.Range();
|
||||
const start = findChildAtTextOffset(parent, startIndex);
|
||||
const end = findChildAtTextOffset(parent, endIndex);
|
||||
range.setStart(start?.node ?? parent, start?.offset ?? 0);
|
||||
range.setEnd(end?.node ?? parent, end?.offset ?? parent.textContent.length);
|
||||
return range;
|
||||
};
|
||||
|
||||
// Process highlights at the paragraph level, where possible, to pick up formatting that crosses Markdown boundaries
|
||||
const isLeaf = node.nodeName === '#text' || node.nodeName === 'P';
|
||||
if (isLeaf) {
|
||||
const childRanges = [];
|
||||
for (const term of searchRegexes) {
|
||||
const matches = node.textContent.matchAll(term);
|
||||
|
||||
for (const match of matches) {
|
||||
const startIndex = match.index ?? 0;
|
||||
const endIndex = (match.index ?? 0) + match[0].length;
|
||||
if (startIndex !== endIndex) {
|
||||
childRanges.push(buildRange(node, startIndex, endIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (childRanges.length > 0) {
|
||||
ranges.set(node, childRanges);
|
||||
}
|
||||
} else {
|
||||
for (const child of node.childNodes) {
|
||||
processNode(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closestParagraph = (node: Node) => node.parentElement?.closest('p');
|
||||
|
||||
const onNodeChanged = (node: Node) => {
|
||||
highlight?.clear();
|
||||
|
||||
processNode(closestParagraph(node) ?? node);
|
||||
|
||||
highlight = new editorWindow.Highlight(...[...ranges.values()].flat());
|
||||
editorWindow.CSS.highlights.set(searchHighlightKey, highlight);
|
||||
};
|
||||
|
||||
const onNodeRemoved = (node: Node, parent: Node) => {
|
||||
const removeFromRanges = (node: Node) => {
|
||||
ranges.delete(node);
|
||||
for (const child of node.childNodes) {
|
||||
removeFromRanges(child);
|
||||
}
|
||||
};
|
||||
removeFromRanges(node);
|
||||
|
||||
// Handle the case where removing a node created a match:
|
||||
onNodeChanged(closestParagraph(parent) ?? parent);
|
||||
};
|
||||
|
||||
const clearHighlights = () => {
|
||||
highlight?.clear();
|
||||
editorWindow.CSS.highlights.delete(searchHighlightKey);
|
||||
ranges.clear();
|
||||
};
|
||||
|
||||
return { onNodeChanged, onNodeRemoved, clearHighlights, canHighlight: searchRegexes.length > 0 };
|
||||
}, [editor, searchRegexes]);
|
||||
};
|
||||
|
||||
const useHighlightedSearchTerms = (editor: Editor, searchTerms: HighlightedWord[], themeId: number) => {
|
||||
const searchRegexes = useSearchRegexes(searchTerms);
|
||||
useHighlightStyleSheet(editor, themeId);
|
||||
const highlighter = useHighlighter(editor, searchRegexes);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !highlighter.canHighlight) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const editorBody = editor.getBody();
|
||||
|
||||
type NodeChangeEvent = { element: Element };
|
||||
const onNodeChange = ({ element }: EditorEvent<NodeChangeEvent>) => {
|
||||
highlighter.onNodeChanged(element);
|
||||
};
|
||||
|
||||
const onKeyUp = (_event: KeyboardEvent) => {
|
||||
// Use selectedNode and not event.target -- event.target seems to always point
|
||||
// to the body.
|
||||
const selectedNode = editor.selection.getNode();
|
||||
if (selectedNode) {
|
||||
highlighter.onNodeChanged(selectedNode);
|
||||
}
|
||||
};
|
||||
|
||||
const onSetContent = () => {
|
||||
highlighter.onNodeChanged(editorBody);
|
||||
};
|
||||
|
||||
editor.on('NodeChange', onNodeChange);
|
||||
editor.on('SetContent', onSetContent);
|
||||
|
||||
// NodeChange doesn't fire while typing, so we also need keyup
|
||||
editor.on('keyup', onKeyUp);
|
||||
|
||||
// NodeChange also doesn't fire for certain DOM changes (e.g. when TinyMCE-provided search highlights are added/removed):
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.removedNodes) {
|
||||
highlighter.onNodeRemoved(node, mutation.target);
|
||||
}
|
||||
|
||||
for (const node of mutation.addedNodes) {
|
||||
highlighter.onNodeChanged(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(editorBody, { subtree: true, childList: true });
|
||||
|
||||
onSetContent();
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
highlighter.clearHighlights();
|
||||
|
||||
editor.off('NodeChange', onNodeChange);
|
||||
editor.off('keyup', onKeyUp);
|
||||
editor.off('SetContent', onSetContent);
|
||||
};
|
||||
}, [editor, highlighter]);
|
||||
};
|
||||
|
||||
export default useHighlightedSearchTerms;
|
||||
@@ -6,9 +6,7 @@ import shim from '@joplin/lib/shim';
|
||||
const useLinkTooltips = (editor: Editor|null) => {
|
||||
const resetModifiedTitles = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const doc = editor.getDoc();
|
||||
if (!doc) return;
|
||||
for (const element of doc.querySelectorAll('a[data-joplin-original-title]')) {
|
||||
for (const element of editor.getDoc().querySelectorAll('a[data-joplin-original-title]')) {
|
||||
element.setAttribute('title', element.getAttribute('data-joplin-original-title') ?? '');
|
||||
element.removeAttribute('data-joplin-original-title');
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { WhiteboardNodeData } from '../canvasFlow';
|
||||
import useCheckboxToggle from '../useCheckboxToggle';
|
||||
import handlePositions from './handlePositions';
|
||||
import useCardWheel from './useCardWheel';
|
||||
import stripUnsafeStyles from './stripUnsafeStyles';
|
||||
|
||||
const logger = Logger.create('WhiteboardFileNode');
|
||||
|
||||
@@ -125,7 +124,7 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
|
||||
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, resolved.body, {
|
||||
resourceInfos: linkedResources,
|
||||
});
|
||||
if (!cancelled) setNoteHtml(stripUnsafeStyles(result?.html ?? ''));
|
||||
if (!cancelled) setNoteHtml(result?.html ?? '');
|
||||
} catch {
|
||||
if (!cancelled) setNoteHtml('');
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { WhiteboardNodeData } from '../canvasFlow';
|
||||
import useCheckboxToggle from '../useCheckboxToggle';
|
||||
import handlePositions from './handlePositions';
|
||||
import useCardWheel from './useCardWheel';
|
||||
import stripUnsafeStyles from './stripUnsafeStyles';
|
||||
|
||||
const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
|
||||
const [html, setHtml] = useState<string>('');
|
||||
@@ -27,7 +26,7 @@ const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
|
||||
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, md, {
|
||||
resourceInfos: ctx.resourceInfos,
|
||||
});
|
||||
if (!cancelled) setHtml(stripUnsafeStyles(result?.html ?? ''));
|
||||
if (!cancelled) setHtml(result?.html ?? '');
|
||||
} catch {
|
||||
if (!cancelled) setHtml('');
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import stripUnsafeStyles from './stripUnsafeStyles';
|
||||
|
||||
describe('stripUnsafeStyles', () => {
|
||||
|
||||
test.each([
|
||||
[
|
||||
'removes a style element',
|
||||
'<p>Hello</p><style>.rli-sideBar { outline: 3px solid magenta; }</style>',
|
||||
'<p>Hello</p>',
|
||||
],
|
||||
[
|
||||
'removes a style element containing a remote @import',
|
||||
'<style>@import url(http://evil.example/beacon.css);</style><p>Hi</p>',
|
||||
'<p>Hi</p>',
|
||||
],
|
||||
[
|
||||
'removes multiple style elements',
|
||||
'<style>a{}</style><p>x</p><style>b{}</style>',
|
||||
'<p>x</p>',
|
||||
],
|
||||
[
|
||||
'removes an uppercase STYLE element',
|
||||
'<P>x</P><STYLE>@import url(http://evil.example/x.css);</STYLE>',
|
||||
'<p>x</p>',
|
||||
],
|
||||
[
|
||||
'removes a mixed-case Style element',
|
||||
'<Style>a{}</Style><p>y</p>',
|
||||
'<p>y</p>',
|
||||
],
|
||||
[
|
||||
'keeps rich card content (images, formatting, links)',
|
||||
'<p><strong>Bold</strong> <a href="https://example.com">link</a> <img src="x.png"></p>',
|
||||
'<p><strong>Bold</strong> <a href="https://example.com">link</a> <img src="x.png"></p>',
|
||||
],
|
||||
[
|
||||
'keeps inline style attributes, which cannot reach the app chrome',
|
||||
'<p style="color: red">text</p>',
|
||||
'<p style="color: red">text</p>',
|
||||
],
|
||||
])('should %s', (_label, input, expected) => {
|
||||
expect(stripUnsafeStyles(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test('should return empty input unchanged', () => {
|
||||
expect(stripUnsafeStyles('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
// Whiteboard cards render into the main document, not the isolated note-viewer
|
||||
// iframe, so a note's <style> would reach the app chrome. Removing <style>
|
||||
// elements also drops any @import; inline style="" attributes are kept as they
|
||||
// only affect the card itself.
|
||||
const stripUnsafeStyles = (html: string) => {
|
||||
if (!html || !/<style/i.test(html)) return html;
|
||||
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
for (const style of Array.from(doc.querySelectorAll('style'))) {
|
||||
style.remove();
|
||||
}
|
||||
return doc.body.innerHTML;
|
||||
};
|
||||
|
||||
export default stripUnsafeStyles;
|
||||
@@ -5,7 +5,7 @@ import { flipNthCheckbox } from './useCheckboxToggle';
|
||||
// `flipNthCheckbox` must count exactly the same subset so the Nth rendered
|
||||
// checkbox maps to the Nth source-text checkbox.
|
||||
|
||||
describe('useCheckboxToggle', () => {
|
||||
describe('flipNthCheckbox', () => {
|
||||
test('flips an unchecked box to checked', () => {
|
||||
expect(flipNthCheckbox('- [ ] todo', 0)).toBe('- [x] todo');
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import styles_ from './styles';
|
||||
import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEditorRef, NoteBodyEditorPropsAndRef, NoteBodyEditorType } from './utils/types';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import eventManager, { EventName, NoteLockNoteStateChangeEvent } from '@joplin/lib/eventManager';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
@@ -28,10 +28,6 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
import { itemIsReadOnly } from '@joplin/lib/models/utils/readOnly';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import NoteLockPanel from './NoteLockPanel/NoteLockPanel';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { substrWithEllipsis } from '@joplin/lib/string-utils';
|
||||
import NoteSearchBar from '../NoteSearchBar';
|
||||
@@ -66,7 +62,6 @@ import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
|
||||
import getResourceBaseUrl from './utils/getResourceBaseUrl';
|
||||
import useInitialCursorLocation from './utils/useInitialCursorLocation';
|
||||
import NotePositionService, { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
|
||||
import { blur } from '@joplin/lib/utils/focusHandler';
|
||||
|
||||
const debounce = require('debounce');
|
||||
|
||||
@@ -85,7 +80,6 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
|
||||
const [isReadOnly, setIsReadOnly] = useState<boolean>(false);
|
||||
const [reloadInProgress, setReloadInProgress] = useState(false);
|
||||
|
||||
const editorRef = useRef<NoteBodyEditorRef|null>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement|null>(null);
|
||||
@@ -98,20 +92,11 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
}, []);
|
||||
|
||||
const setFormNoteRef = useRef<OnSetFormNote>(null);
|
||||
const formNoteRef = useRef<FormNote>(null);
|
||||
const { saveNoteIfWillChange, scheduleSaveNote } = useScheduleSaveCallbacks({
|
||||
setFormNote: setFormNoteRef, formNote: formNoteRef, dispatch: props.dispatch, editorRef, editorId,
|
||||
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
|
||||
setFormNote: setFormNoteRef, dispatch: props.dispatch, editorRef, editorId,
|
||||
});
|
||||
const formNote_beforeLoad = useCallback(async (event: OnLoadEvent) => {
|
||||
await saveNoteIfWillChange(event.formNote);
|
||||
// The lock must wait for the pending saves of the note being left: a locked note's save
|
||||
// needs the unlocked session to encrypt, so locking first would drop the last edits.
|
||||
// The note being left may have no form note at all (unlock or cannot-decrypt overlay).
|
||||
if (isNoteLockEnabled() && Setting.value('noteLock.lockOnNoteSwitch')) {
|
||||
await event.formNote.saveActionQueue?.waitForAllDone();
|
||||
NoteLockSession.instance().lock();
|
||||
}
|
||||
setShowRevisions(false);
|
||||
}, [saveNoteIfWillChange]);
|
||||
|
||||
@@ -122,37 +107,8 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const effectiveNoteId = useEffectiveNoteId(props);
|
||||
const { editorPlugin, editorView } = usePluginEditorView(props.plugins);
|
||||
const builtInEditorVisible = !editorPlugin;
|
||||
const windowId = useContext(WindowIdContext);
|
||||
const onDecryptFailedChange = useCallback((value: boolean) => {
|
||||
props.dispatch({ type: 'SET_ACTIVE_NOTE_IS_UNDECRYPTABLE', value, windowId });
|
||||
}, [props.dispatch, windowId]);
|
||||
const onReloadInProgressChange = useCallback((value: boolean) => {
|
||||
if (value) {
|
||||
// TinyMCE edits inside its own document. Blurring only the outer window does
|
||||
// not reliably stop input when the editor is in a secondary window.
|
||||
editorRef.current?.blurEditor?.();
|
||||
|
||||
// Blur only the window whose editor is being refreshed. A secondary window
|
||||
// can refresh in response to a change saved by the main editor; blurring the
|
||||
// global document here would incorrectly interrupt typing in the main window.
|
||||
const editorWindowActiveElement = containerRef.current?.ownerDocument?.activeElement;
|
||||
if (editorWindowActiveElement) {
|
||||
blur('NoteEditor::reloadEditorWindow', editorWindowActiveElement);
|
||||
}
|
||||
}
|
||||
setReloadInProgress(value);
|
||||
if (!value) {
|
||||
// TinyMCE can report onWillChange before a reload, then have its delayed
|
||||
// onChange rejected while the reload is in progress. Since no save is queued
|
||||
// in that case, clear the saving status when the reload completes.
|
||||
props.dispatch({
|
||||
type: 'EDITOR_NOTE_STATUS_REMOVE',
|
||||
id: effectiveNoteId,
|
||||
});
|
||||
}
|
||||
}, [effectiveNoteId, props.dispatch]);
|
||||
|
||||
const { formNote, setFormNote, isNewNote, resourceInfos, decryptFailed, loadBlocked } = useFormNote({
|
||||
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
|
||||
noteId: effectiveNoteId,
|
||||
isProvisional: props.isProvisional,
|
||||
titleInputRef: titleInputRef,
|
||||
@@ -161,48 +117,14 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
onAfterLoad: formNote_afterLoad,
|
||||
builtInEditorVisible,
|
||||
editorId,
|
||||
noteLockSessionUnlocked: props.noteLockSessionUnlocked,
|
||||
onDecryptFailedChange,
|
||||
onReloadInProgressChange,
|
||||
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
|
||||
});
|
||||
setFormNoteRef.current = setFormNote;
|
||||
const formNoteRef = useRef<FormNote>(formNote);
|
||||
formNoteRef.current = { ...formNote };
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNoteLockEnabled()) return () => {};
|
||||
// Pending scheduled saves read the lock state from the form note, so it must follow an
|
||||
// enable/disable triggered outside the editor (note list menu, another window) immediately.
|
||||
const onLockStateChange = (event: NoteLockNoteStateChangeEvent) => {
|
||||
if (formNoteRef.current.id !== event.noteId) return;
|
||||
// Enabling requires an unlocked session, so capture the key the same way a decrypt
|
||||
// does - a pending save can then still encrypt if the session locks before it runs.
|
||||
const noteLockKey = event.isLocked && NoteLockSession.instance().isUnlocked() ? NoteLockSession.instance().decryptedKey() : null;
|
||||
const newFormNote = { ...formNoteRef.current, is_locked: event.isLocked ? 1 : 0, noteLockKey, isDecrypted: event.isLocked };
|
||||
setFormNote(newFormNote);
|
||||
void scheduleSaveNote(newFormNote);
|
||||
};
|
||||
eventManager.on(EventName.NoteLockNoteStateChange, onLockStateChange);
|
||||
return () => {
|
||||
eventManager.off(EventName.NoteLockNoteStateChange, onLockStateChange);
|
||||
};
|
||||
}, [setFormNote, scheduleSaveNote]);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!isNoteLockEnabled() || props.noteLockSessionUnlocked) return;
|
||||
const lockedFormNote = formNoteRef.current;
|
||||
if (!lockedFormNote?.is_locked || !lockedFormNote.hasChanged) return;
|
||||
// The panel replaces the editor only once the edits are written, so flush them the same way
|
||||
// a note switch does before locking.
|
||||
await saveNoteIfWillChange(lockedFormNote);
|
||||
// The editor dispatches its pending change when it unmounts, which would be saved over the
|
||||
// flushed one - clearing the change id makes onFieldChange drop it, as a note switch does.
|
||||
setFormNote(prev => ({ ...prev, bodyWillChangeId: 0 }));
|
||||
await lockedFormNote.saveActionQueue?.waitForAllDone();
|
||||
}, [props.noteLockSessionUnlocked, saveNoteIfWillChange]);
|
||||
|
||||
const formNoteFolder = useFolder({ folderId: formNote.parent_id });
|
||||
|
||||
const windowId = useContext(WindowIdContext);
|
||||
const shownEditorViewIds = useVisiblePluginEditorViewIds(props.plugins, windowId);
|
||||
useConnectToEditorPlugin({
|
||||
startupPluginsLoaded: props.startupPluginsLoaded,
|
||||
@@ -288,7 +210,6 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
}, [formNote.title, props.onTitleChange]);
|
||||
|
||||
const onFieldChange = useCallback(async (field: string, value: string, changeId = 0) => {
|
||||
if (reloadInProgress) return;
|
||||
if (!isMountedRef.current) {
|
||||
// When the component is unmounted, various actions can happen which can
|
||||
// trigger onChange events, for example the textarea might be cleared.
|
||||
@@ -337,7 +258,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
// - debounced because many calls to scheduleSaveNote can resolve at once
|
||||
scheduleNoteListResort();
|
||||
}
|
||||
}, [reloadInProgress, handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
|
||||
}, [handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
|
||||
|
||||
const onDrop = useDropHandler({ editorRef });
|
||||
|
||||
@@ -496,16 +417,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
return <div style={emptyDivStyle} ref={containerRef}></div>;
|
||||
}
|
||||
|
||||
const searchMarkers = useSearchMarkers({
|
||||
showLocalSearch,
|
||||
localSearchMarkerOptions,
|
||||
noteId: props.noteId,
|
||||
searchResults: props.searchResults,
|
||||
searchId: props.selectedSearchId,
|
||||
searches: props.searches,
|
||||
highlightedWords: props.highlightedWords,
|
||||
noteTitle: formNote.title,
|
||||
});
|
||||
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
|
||||
const initialCursorLocation = useInitialCursorLocation({
|
||||
noteId: props.noteId,
|
||||
});
|
||||
@@ -522,14 +434,13 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
content: formNote.body,
|
||||
contentMarkupLanguage: markupLanguage,
|
||||
contentOriginalCss: formNote.originalCss,
|
||||
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
|
||||
initialCursorLocation,
|
||||
resourceInfos: resourceInfos,
|
||||
resourceDirectory: Setting.value('resourceDir'),
|
||||
htmlToMarkdown: htmlToMarkdown,
|
||||
markupToHtml: markupToHtml,
|
||||
allAssets: allAssets,
|
||||
disabled: isReadOnly || reloadInProgress,
|
||||
disabled: isReadOnly,
|
||||
themeId: props.themeId,
|
||||
dispatch: props.dispatch,
|
||||
noteToolbar: null,
|
||||
@@ -558,9 +469,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
useCustomPdfViewer: props.useCustomPdfViewer,
|
||||
// We need it to identify the context for which media is rendered.
|
||||
// It is currently used to remember pdf scroll position for each attachments of each note uniquely.
|
||||
// Use the form note object to ensure the editor is associated with the loaded form note until onBeforeLoad
|
||||
// has flushed any pending changes from it.
|
||||
noteId: formNote.id,
|
||||
noteId: props.noteId,
|
||||
watchedNoteFiles: props.watchedNoteFiles,
|
||||
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
|
||||
showNoteLinkIcon: props.showNoteLinkIcon,
|
||||
@@ -583,7 +492,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
}, [noteHasWhiteboardFence, props.dispatch]);
|
||||
|
||||
if (useWhiteboardEditor) {
|
||||
editor = <WhiteboardEditor key={formNote.id} {...editorProps}/>;
|
||||
editor = <WhiteboardEditor {...editorProps}/>;
|
||||
} else if (builtInEditorVisible) {
|
||||
if (props.bodyEditor === 'TinyMCE') {
|
||||
editor = <TinyMCE {...editorProps}/>;
|
||||
@@ -751,43 +660,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
/>;
|
||||
};
|
||||
|
||||
// A locked note has no form note while the session is locked (see loadNoteForForm), so the
|
||||
// panel is driven by the note metadata. A loaded form note stays mounted on lock only if it
|
||||
// has unsaved changes, so they are not thrown away.
|
||||
const lockedNoteMetadata = isNoteLockEnabled() && effectiveNoteId ? props.notes.find(n => n.id === effectiveNoteId) : null;
|
||||
if (lockedNoteMetadata?.is_locked) {
|
||||
// The session is unlocked but the note content failed to decrypt (e.g. it was encrypted
|
||||
// prior to a password reset) - locking the session again shows the regular unlock panel.
|
||||
if (decryptFailed && props.noteLockSessionUnlocked) {
|
||||
return (
|
||||
<div style={styles.root} ref={containerRef}>
|
||||
<NoteLockPanel
|
||||
noteTitle={lockedNoteMetadata.title}
|
||||
hasNoteLockKey={props.hasNoteLockKey}
|
||||
dispatch={props.dispatch}
|
||||
undecryptable={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const formNoteLoaded = formNote.id === effectiveNoteId;
|
||||
const showLockPanel = formNoteLoaded
|
||||
? !props.noteLockSessionUnlocked && !formNote.hasChanged
|
||||
: !props.noteLockSessionUnlocked || loadBlocked;
|
||||
if (showLockPanel) {
|
||||
return (
|
||||
<div style={styles.root} ref={containerRef}>
|
||||
<NoteLockPanel
|
||||
noteTitle={lockedNoteMetadata.title}
|
||||
hasNoteLockKey={props.hasNoteLockKey}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((!editorPlugin && reloadInProgress) || formNote.encryption_applied || !formNote.id || !effectiveNoteId) {
|
||||
if (formNote.encryption_applied || !formNote.id || !effectiveNoteId) {
|
||||
return renderNoNotes(styles.root);
|
||||
}
|
||||
|
||||
@@ -799,7 +672,6 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const note = props.notes.find(n => n.id === props.selectedNoteIds[0]);
|
||||
if (!note) return null;
|
||||
if (note.markup_language !== MarkupLanguage.Html) return null;
|
||||
if (isNoteLockEnabled() && note.is_locked && !props.noteLockSessionUnlocked) return null;
|
||||
|
||||
return (
|
||||
<div style={styles.resourceWatchBanner}>
|
||||
@@ -828,7 +700,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
noteTitle={formNote.title}
|
||||
noteUserUpdatedTime={formNote.user_updated_time}
|
||||
onTitleChange={onTitleChange}
|
||||
disabled={isReadOnly || reloadInProgress}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{renderSearchInfo()}
|
||||
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%', minHeight: '0' }}>
|
||||
@@ -853,15 +725,6 @@ interface ConnectProps {
|
||||
windowId: string;
|
||||
}
|
||||
|
||||
// Memoized because mapStateToProps runs on every dispatch and SyncInfo parses the cached JSON.
|
||||
let hasNoteLockKeyCache: { syncInfoCache: string; value: boolean } = null;
|
||||
const hasNoteLockKey = (syncInfoCache: string) => {
|
||||
if (!hasNoteLockKeyCache || hasNoteLockKeyCache.syncInfoCache !== syncInfoCache) {
|
||||
hasNoteLockKeyCache = { syncInfoCache, value: !!new SyncInfo(syncInfoCache).noteLockKey };
|
||||
}
|
||||
return hasNoteLockKeyCache.value;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
|
||||
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
|
||||
@@ -921,9 +784,6 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
enableInEditorRendering: state.settings['editor.inlineRendering'],
|
||||
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
|
||||
whiteboardForceMarkdown: windowState.whiteboardForceMarkdown ?? {},
|
||||
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
|
||||
hasNoteLockKey: hasNoteLockKey(state.settings['syncInfoCache']),
|
||||
editorNoteReloadTimeRequest: windowState.windowEditorNoteReloadTimeRequest,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import Button, { ButtonLevel } from '../../Button/Button';
|
||||
import LabelledPasswordInput from '../../PasswordInput/LabelledPasswordInput';
|
||||
|
||||
interface Props {
|
||||
noteTitle: string;
|
||||
hasNoteLockKey: boolean;
|
||||
dispatch: Dispatch;
|
||||
undecryptable?: boolean;
|
||||
}
|
||||
|
||||
export default function NoteLockPanel(props: Props) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const onPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(event.target.value);
|
||||
}, []);
|
||||
|
||||
const unlock = useCallback(async () => {
|
||||
if (!password || unlocking) return;
|
||||
setUnlocking(true);
|
||||
try {
|
||||
await NoteLockSession.instance().unlock(password);
|
||||
// No success handling: the unlock event swaps this panel out for the note.
|
||||
} catch (error) {
|
||||
// WebCrypto reports a wrong password as a generic OperationError.
|
||||
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
|
||||
setUnlocking(false);
|
||||
}
|
||||
}, [password, unlocking]);
|
||||
|
||||
const onSubmit = useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
void unlock();
|
||||
}, [unlock]);
|
||||
|
||||
const onSetUpClick = useCallback(() => {
|
||||
props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Config',
|
||||
props: { defaultSection: 'noteLock' },
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const renderAction = () => {
|
||||
if (props.undecryptable) {
|
||||
return <p className="message">{_('This note could not be unlocked. If it was locked prior to a password reset, the content is 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 locked. Enter your password to unlock your notes for this session.')}</p>
|
||||
<div className="field">
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Note lock password')}
|
||||
value={password}
|
||||
onChange={onPasswordChange}
|
||||
/>
|
||||
</div>
|
||||
{!!errorMessage && <p className="error" role="alert">{errorMessage}</p>}
|
||||
<Button type='button' level={ButtonLevel.Primary} title={_('Unlock')} disabled={!password || unlocking} onClick={unlock} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="note-lock-panel">
|
||||
<i className="icon fas fa-lock" role="img" aria-label={_('Locked note')}></i>
|
||||
<h2 className="title">{props.noteTitle}</h2>
|
||||
{renderAction()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
.note-lock-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: var(--joplin-main-padding);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note-lock-panel > .icon {
|
||||
font-size: 32px;
|
||||
color: var(--joplin-color-faded);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.note-lock-panel > .title {
|
||||
margin: 0 0 8px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.note-lock-panel > .message {
|
||||
max-width: 420px;
|
||||
color: var(--joplin-color-faded);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.note-lock-panel > .unlock-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note-lock-panel > .unlock-form > .message {
|
||||
max-width: 420px;
|
||||
color: var(--joplin-color-faded);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.note-lock-panel > .unlock-form > .field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.note-lock-panel > .unlock-form > .error {
|
||||
color: var(--joplin-color-error);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { FormNote } from './types';
|
||||
|
||||
import HtmlToMd, { ParseOptions } from '@joplin/lib/HtmlToMd';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
|
||||
export async function htmlToMarkdown(markupLanguage: number, html: string, originalCss: string, parseOptions: ParseOptions = null): Promise<string> {
|
||||
@@ -25,7 +26,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
|
||||
return newBody;
|
||||
}
|
||||
|
||||
export async function formNoteToNote(formNote: FormNote) {
|
||||
export async function formNoteToNote(formNote: FormNote): Promise<NoteEntity> {
|
||||
return {
|
||||
id: formNote.id,
|
||||
// Should also include parent_id and deleted_time so that the reducer
|
||||
@@ -36,7 +37,5 @@ export async function formNoteToNote(formNote: FormNote) {
|
||||
is_conflict: formNote.is_conflict,
|
||||
title: formNote.title,
|
||||
body: formNote.body,
|
||||
is_locked: formNote.is_locked,
|
||||
isDecrypted: formNote.isDecrypted,
|
||||
};
|
||||
}
|
||||
@@ -16,8 +16,6 @@ import { RefObject, SetStateAction } from 'react';
|
||||
import * as React from 'react';
|
||||
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
|
||||
import { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
|
||||
import type { DecryptedNoteLockKey } from '@joplin/lib/services/noteLock/NoteLockKey';
|
||||
import { HighlightedWord, SearchEntry } from '@joplin/lib/reducer';
|
||||
|
||||
export interface AllAssetsOptions {
|
||||
contentMaxWidthTarget?: string;
|
||||
@@ -49,12 +47,13 @@ export interface NoteEditorProps {
|
||||
notesParentType: string;
|
||||
selectedNoteTags: TagEntity[];
|
||||
selectedNoteHash: string;
|
||||
searches: SearchEntry[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- searches: any[] is the shape in lib's reducer; tightening requires updating the reducer first
|
||||
searches: any[];
|
||||
selectedSearchId: string;
|
||||
customCss: string;
|
||||
noteVisiblePanes: string[];
|
||||
watchedResources: Record<string, unknown>;
|
||||
highlightedWords: HighlightedWord[];
|
||||
highlightedWords: string[];
|
||||
tabMovesFocus: boolean;
|
||||
plugins: PluginStates;
|
||||
toolbarButtonInfos: ToolbarItem[];
|
||||
@@ -74,14 +73,10 @@ export interface NoteEditorProps {
|
||||
enableHtmlToMarkdownBanner: boolean;
|
||||
showNoteLinkIcon: boolean;
|
||||
whiteboardForceMarkdown: Record<string, boolean>;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
hasNoteLockKey: boolean;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorRef {
|
||||
content(): string|Promise<string>;
|
||||
blurEditor?(): void;
|
||||
resetScroll(): void;
|
||||
scrollTo(options: ScrollOptions): void;
|
||||
|
||||
@@ -118,7 +113,6 @@ export interface NoteBodyEditorProps {
|
||||
contentKey: string;
|
||||
contentMarkupLanguage: number;
|
||||
contentOriginalCss: string;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
initialCursorLocation: EditorCursorLocations;
|
||||
onChange(event: OnChangeEvent): void;
|
||||
onWillChange(event: { changeId: number }): void;
|
||||
@@ -174,12 +168,6 @@ export interface FormNote {
|
||||
markup_language: number;
|
||||
user_updated_time: number;
|
||||
encryption_applied: number;
|
||||
is_locked: number;
|
||||
// The key captured when the locked note was decrypted; pending saves encrypt with it so they
|
||||
// can complete even if the session locks before they run.
|
||||
noteLockKey: DecryptedNoteLockKey|null;
|
||||
// Proof for gated saves that the body is plaintext.
|
||||
isDecrypted: boolean;
|
||||
deleted_time: number;
|
||||
|
||||
hasChanged: boolean;
|
||||
@@ -232,9 +220,6 @@ export function defaultFormNote(): FormNote {
|
||||
hasChanged: false,
|
||||
user_updated_time: 0,
|
||||
encryption_applied: 0,
|
||||
is_locked: 0,
|
||||
noteLockKey: null,
|
||||
isDecrypted: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import useFormNote, { HookDependencies } from './useFormNote';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import { join } from 'path';
|
||||
import { formNoteToNote } from '.';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
|
||||
const defaultFormNoteProps: HookDependencies = {
|
||||
noteId: '',
|
||||
@@ -20,16 +16,6 @@ const defaultFormNoteProps: HookDependencies = {
|
||||
onAfterLoad: () => { },
|
||||
editorId: 'editor',
|
||||
builtInEditorVisible: false,
|
||||
noteLockSessionUnlocked: false,
|
||||
onDecryptFailedChange: () => {},
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve: (value: T)=> void;
|
||||
const promise = new Promise<T>(resolvePromise => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve: resolve! };
|
||||
};
|
||||
|
||||
describe('useFormNote', () => {
|
||||
@@ -38,99 +24,6 @@ describe('useFormNote', () => {
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
// The session and decryption internals are covered by the lib tests; here they are mocked to
|
||||
// test only the hook's gating: ciphertext must never reach the form note.
|
||||
it('should not produce a form note for a locked note while the session is locked, and decrypt it once unlocked', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
// A direct save of a new note with is_locked keeps the raw body, standing in for ciphertext.
|
||||
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
|
||||
|
||||
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(false);
|
||||
const decryptedKeyMock = jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
|
||||
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => ({ ...note, body: 'secret content' }));
|
||||
const onBeforeLoad = jest.fn();
|
||||
const onDecryptFailedChange = jest.fn();
|
||||
|
||||
try {
|
||||
const lockedRender = renderHook(props => useFormNote(props), {
|
||||
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange },
|
||||
});
|
||||
// The blocked load produces no state change to wait for; onBeforeLoad is its last
|
||||
// await, so once it has run a single flush completes the branch.
|
||||
await waitFor(() => expect(onBeforeLoad).toHaveBeenCalled());
|
||||
await act(async () => {});
|
||||
expect(lockedRender.result.current.formNote).toMatchObject({
|
||||
id: '',
|
||||
body: '',
|
||||
noteLockKey: null,
|
||||
});
|
||||
expect(lockedRender.result.current.loadBlocked).toBe(true);
|
||||
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(false);
|
||||
|
||||
isUnlockedMock.mockReturnValue(true);
|
||||
lockedRender.rerender({ ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange, noteLockSessionUnlocked: true });
|
||||
await waitFor(() => {
|
||||
expect(lockedRender.result.current.formNote).toMatchObject({
|
||||
id: testNote.id,
|
||||
is_locked: 1,
|
||||
body: 'secret content',
|
||||
noteLockKey: { id: 'key-id', plainText: 'key' },
|
||||
});
|
||||
});
|
||||
expect(lockedRender.result.current.loadBlocked).toBe(false);
|
||||
lockedRender.unmount();
|
||||
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
isUnlockedMock.mockReturnValue(false);
|
||||
const flagOffRender = renderHook(props => useFormNote(props), {
|
||||
initialProps: { ...defaultFormNoteProps, noteId: testNote.id },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(flagOffRender.result.current.formNote.id).toBe(testNote.id);
|
||||
});
|
||||
expect(flagOffRender.result.current.formNote).toMatchObject({
|
||||
body: 'ciphertext',
|
||||
});
|
||||
flagOffRender.unmount();
|
||||
} finally {
|
||||
isUnlockedMock.mockRestore();
|
||||
decryptedKeyMock.mockRestore();
|
||||
decryptBodyMock.mockRestore();
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should report a decryption failure instead of throwing, without producing a form note', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
|
||||
// The save's own change event would otherwise reach the hook's listener and reload the note.
|
||||
await ItemChange.waitForAllSaved();
|
||||
|
||||
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
|
||||
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockRejectedValue(new Error('OperationError'));
|
||||
const onDecryptFailedChange = jest.fn();
|
||||
|
||||
try {
|
||||
const render = renderHook(props => useFormNote(props), {
|
||||
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, noteLockSessionUnlocked: true, onDecryptFailedChange },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(render.result.current.decryptFailed).toBe(true);
|
||||
});
|
||||
expect(render.result.current.formNote).toMatchObject({
|
||||
id: '',
|
||||
body: '',
|
||||
});
|
||||
expect(render.result.current.loadBlocked).toBe(false);
|
||||
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(true);
|
||||
render.unmount();
|
||||
} finally {
|
||||
isUnlockedMock.mockRestore();
|
||||
decryptBodyMock.mockRestore();
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should update note when decryption completes', async () => {
|
||||
const testNote = await Note.save({ title: 'Test Note!' });
|
||||
|
||||
@@ -208,7 +101,7 @@ describe('useFormNote', () => {
|
||||
is_conflict: 1,
|
||||
title: testNote.title,
|
||||
});
|
||||
}, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
// Should preserve is_conflict after save.
|
||||
expect(await formNoteToNote(formNote.result.current.formNote)).toMatchObject({
|
||||
@@ -222,12 +115,10 @@ describe('useFormNote', () => {
|
||||
|
||||
it('should reload the note when it is changed outside of the editor', async () => {
|
||||
const note = await Note.save({ title: 'Test Note!', body: '...' });
|
||||
const onReloadInProgressChange = jest.fn();
|
||||
|
||||
const props = {
|
||||
...defaultFormNoteProps,
|
||||
noteId: note.id,
|
||||
onReloadInProgressChange,
|
||||
};
|
||||
|
||||
const formNote = renderHook(props => useFormNote(props), {
|
||||
@@ -246,59 +137,10 @@ describe('useFormNote', () => {
|
||||
await waitFor(() => {
|
||||
expect(formNote.result.current.formNote.title).toBe('Modified');
|
||||
});
|
||||
expect(onReloadInProgressChange).not.toHaveBeenCalled();
|
||||
|
||||
formNote.unmount();
|
||||
});
|
||||
|
||||
it('should force a reload when editorNoteReloadTimeRequest changes', async () => {
|
||||
const note = await Note.save({ title: 'Original', body: '...' });
|
||||
const onReloadInProgressChange = jest.fn();
|
||||
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
|
||||
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
|
||||
|
||||
act(() => {
|
||||
formNote.result.current.setFormNote(previous => ({ ...previous, title: 'Unsaved local title', hasChanged: true }));
|
||||
});
|
||||
await Note.save({ id: note.id, title: 'Remote title' }, { changeId: 'test-editor' });
|
||||
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
|
||||
|
||||
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Remote title'));
|
||||
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
|
||||
formNote.unmount();
|
||||
});
|
||||
|
||||
it('should clear reloadInProgress after overlapping reload requests', async () => {
|
||||
const note = await Note.save({ title: 'Original', body: '...' });
|
||||
const onReloadInProgressChange = jest.fn();
|
||||
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
|
||||
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
|
||||
|
||||
const firstLoad = deferred<typeof note>();
|
||||
const lastLoad = deferred<typeof note>();
|
||||
const loadMock = jest.spyOn(Note, 'load')
|
||||
.mockImplementationOnce(() => firstLoad.promise)
|
||||
.mockImplementationOnce(() => lastLoad.promise);
|
||||
|
||||
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
|
||||
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(1));
|
||||
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 2 });
|
||||
|
||||
firstLoad.resolve({ ...note, title: 'First reload' });
|
||||
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(2));
|
||||
expect(onReloadInProgressChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
lastLoad.resolve({ ...note, title: 'Last reload' });
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Last reload'));
|
||||
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
|
||||
|
||||
loadMock.mockRestore();
|
||||
formNote.unmount();
|
||||
});
|
||||
|
||||
test('should refresh resource infos when changed outside the editor', async () => {
|
||||
let note = await Note.save({});
|
||||
note = await shim.attachFileToNote(note, join(supportDir, 'sample.txt'));
|
||||
|
||||
@@ -9,15 +9,12 @@ import attachedResources, { clearResourceCache } from '@joplin/lib/utils/attache
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';
|
||||
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
|
||||
const logger = Logger.create('useFormNote');
|
||||
|
||||
@@ -34,10 +31,6 @@ export interface HookDependencies {
|
||||
onBeforeLoad(event: OnLoadEvent): void;
|
||||
onAfterLoad(event: OnLoadEvent): void;
|
||||
builtInEditorVisible: boolean;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
onDecryptFailedChange(value: boolean): void;
|
||||
onReloadInProgressChange?(value: boolean): void;
|
||||
editorNoteReloadTimeRequest?: number;
|
||||
}
|
||||
|
||||
type MapFormNoteCallback = (previousFormNote: FormNote)=> FormNote;
|
||||
@@ -73,41 +66,16 @@ function resourceInfosChanged(a: ResourceInfos, b: ResourceInfos): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// While the session is locked, a locked note produces no form note at all - the unlock panel
|
||||
// takes the editor's place. So no placeholder body exists that a save could write over the note.
|
||||
const loadNoteForForm = async (noteId: string): Promise<{ note: NoteEntity|null; blocked: boolean; decryptFailed: boolean }> => {
|
||||
if (isNoteLockEnabled()) {
|
||||
const lockState = await Note.load(noteId, { fields: ['is_locked'] });
|
||||
if (lockState && NoteLockNote.isLocked(lockState)) {
|
||||
if (!NoteLockSession.instance().isUnlocked()) return { note: null, blocked: true, decryptFailed: false };
|
||||
try {
|
||||
return { note: await Note.load(noteId, { useNoteLock: true }), blocked: false, decryptFailed: false };
|
||||
} catch (error) {
|
||||
// Happens when the note was encrypted with a different key, e.g. before a password
|
||||
// reset. Fail closed and let the editor show the cannot-decrypt panel.
|
||||
logger.warn('Could not decrypt locked note:', noteId, error);
|
||||
return { note: null, blocked: false, decryptFailed: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { note: await Note.load(noteId), blocked: false, decryptFailed: false };
|
||||
};
|
||||
|
||||
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean, force?: boolean)=> Promise<FormNote>;
|
||||
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId: string, noteId: string, initNoteState: InitNoteStateCallback, clearFormNote: ()=> void, builtInEditorVisible: boolean, noteLockSessionUnlocked: boolean, setDecryptFailed: (value: boolean)=> void, setLoadBlocked: (value: boolean)=> void, onReloadInProgressChange: (value: boolean)=> void, editorNoteReloadTimeRequest: number) => {
|
||||
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean)=> Promise<FormNote>;
|
||||
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId: string, noteId: string, initNoteState: InitNoteStateCallback, builtInEditorVisible: boolean) => {
|
||||
// Increasing the value of this counter cancels any ongoing note refreshes and starts
|
||||
// a new refresh.
|
||||
const [formNoteRefreshRequest, setFormNoteRefreshRequest] = useState({ counter: 0, force: false });
|
||||
const previousEditorNoteReloadTimeRequestRef = useRef(editorNoteReloadTimeRequest);
|
||||
const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);
|
||||
const prevBuiltInEditorVisible = usePrevious<boolean>(builtInEditorVisible);
|
||||
// Seeded because usePrevious defaults to null, which would read as a session change on mount.
|
||||
const prevNoteLockSessionUnlocked = usePrevious<boolean>(noteLockSessionUnlocked, noteLockSessionUnlocked);
|
||||
|
||||
useQueuedAsyncEffect(async (event) => {
|
||||
if (event.cancelled) return;
|
||||
if (formNoteRefreshRequest.counter <= 0) return;
|
||||
const forceRefresh = formNoteRefreshRequest.force;
|
||||
if (formNoteRef.current.hasChanged && !forceRefresh) {
|
||||
if (formNoteRefreshScheduled <= 0) return;
|
||||
if (formNoteRef.current.hasChanged) {
|
||||
logger.info('Form note changed between scheduling a refresh and the refresh itself. Cancelling the refresh.');
|
||||
return;
|
||||
}
|
||||
@@ -115,58 +83,38 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
|
||||
logger.info('Sync has finished and note has never been changed - reloading it');
|
||||
|
||||
const loadNote = async () => {
|
||||
setDecryptFailed(false);
|
||||
const { note: n, blocked, decryptFailed } = await loadNoteForForm(noteId);
|
||||
if (event.cancelled || (formNoteRef.current.hasChanged && !forceRefresh)) return;
|
||||
const n = await Note.load(noteId);
|
||||
if (event.cancelled || formNoteRef.current.hasChanged) return;
|
||||
|
||||
setLoadBlocked(blocked);
|
||||
setDecryptFailed(decryptFailed);
|
||||
if (blocked || decryptFailed) {
|
||||
// The session locked with this note open (or its content stopped decrypting) - drop
|
||||
// the decrypted content and let the corresponding panel take over.
|
||||
if (formNoteRef.current.id) clearFormNote();
|
||||
} else {
|
||||
// Normally should not happened because if the note has been deleted via sync
|
||||
// it would not have been loaded in the editor (due to note selection changing
|
||||
// on delete)
|
||||
if (!n) {
|
||||
logger.warn('Trying to reload note that has been deleted:', noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
await initNoteState(n, false, forceRefresh);
|
||||
if (event.cancelled) return;
|
||||
// Normally should not happened because if the note has been deleted via sync
|
||||
// it would not have been loaded in the editor (due to note selection changing
|
||||
// on delete)
|
||||
if (!n) {
|
||||
logger.warn('Trying to reload note that has been deleted:', noteId);
|
||||
return;
|
||||
}
|
||||
setFormNoteRefreshRequest(oldValue => {
|
||||
|
||||
await initNoteState(n, false);
|
||||
if (event.cancelled) return;
|
||||
setFormNoteRefreshScheduled(oldValue => {
|
||||
// If a new refresh was scheduled between initNoteState
|
||||
// and now:
|
||||
if (oldValue.counter !== formNoteRefreshRequest.counter) {
|
||||
if (oldValue !== formNoteRefreshScheduled) {
|
||||
return oldValue;
|
||||
}
|
||||
// A refresh is no longer scheduled
|
||||
return { counter: 0, force: false };
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await loadNote();
|
||||
} finally {
|
||||
if (forceRefresh && !event.cancelled) onReloadInProgressChange(false);
|
||||
}
|
||||
}, [formNoteRefreshRequest, noteId, editorId, initNoteState, clearFormNote, setDecryptFailed, onReloadInProgressChange]);
|
||||
await loadNote();
|
||||
}, [formNoteRefreshScheduled, noteId, editorId, initNoteState]);
|
||||
|
||||
const refreshFormNote = useCallback((force = false) => {
|
||||
const refreshFormNote = useCallback(() => {
|
||||
// Increase the counter to cancel any ongoing refresh attempts
|
||||
// and start a new one.
|
||||
if (force) onReloadInProgressChange(true);
|
||||
setFormNoteRefreshRequest(previous => ({ counter: previous.counter + 1, force: previous.force || force }));
|
||||
}, [onReloadInProgressChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorNoteReloadTimeRequest === previousEditorNoteReloadTimeRequestRef.current) return;
|
||||
previousEditorNoteReloadTimeRequestRef.current = editorNoteReloadTimeRequest;
|
||||
refreshFormNote(true);
|
||||
}, [editorNoteReloadTimeRequest, refreshFormNote]);
|
||||
setFormNoteRefreshScheduled(count => count + 1);
|
||||
}, []);
|
||||
|
||||
// When switching from the plugin editor to the built-in editor, we refresh the note since the
|
||||
// plugin may have modified it via the data API.
|
||||
@@ -176,14 +124,6 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
|
||||
}
|
||||
}, [builtInEditorVisible, prevBuiltInEditorVisible, refreshFormNote]);
|
||||
|
||||
// Unlocking loads the note the panel was blocking (the form is then empty), locking must drop
|
||||
// a locked note's plaintext - reload to recompute.
|
||||
useEffect(() => {
|
||||
if (isNoteLockEnabled() && prevNoteLockSessionUnlocked !== noteLockSessionUnlocked && (formNoteRef.current.is_locked || formNoteRef.current.id !== noteId)) {
|
||||
refreshFormNote();
|
||||
}
|
||||
}, [noteLockSessionUnlocked, prevNoteLockSessionUnlocked, noteId, formNoteRef, refreshFormNote]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId) return ()=>{};
|
||||
@@ -213,38 +153,18 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
|
||||
|
||||
export default function useFormNote(dependencies: HookDependencies) {
|
||||
const {
|
||||
noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, builtInEditorVisible, editorId, noteLockSessionUnlocked, onDecryptFailedChange,
|
||||
noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, builtInEditorVisible, editorId,
|
||||
} = dependencies;
|
||||
const onReloadInProgressChange = dependencies.onReloadInProgressChange ?? (() => {});
|
||||
const editorNoteReloadTimeRequest = dependencies.editorNoteReloadTimeRequest ?? 0;
|
||||
|
||||
const [formNote, setFormNote] = useState<FormNote>(defaultFormNote());
|
||||
const [isNewNote, setIsNewNote] = useState(false);
|
||||
// Keyed by note id so a failure can never carry over to another note the user switches to.
|
||||
const [decryptFailedId, setDecryptFailedId] = useState<string|null>(null);
|
||||
const setDecryptFailed = useCallback((value: boolean) => {
|
||||
setDecryptFailedId(value ? noteId : null);
|
||||
onDecryptFailedChange(value);
|
||||
}, [noteId, onDecryptFailedChange]);
|
||||
const [loadBlockedId, setLoadBlockedId] = useState<string|null>(null);
|
||||
const setLoadBlocked = useCallback((value: boolean) => setLoadBlockedId(value ? noteId : null), [noteId]);
|
||||
const previousNoteId = usePrevious(formNote.id);
|
||||
const [resourceInfos, setResourceInfos] = useState<ResourceInfos>({});
|
||||
|
||||
const formNoteRef = useRef(formNote);
|
||||
formNoteRef.current = formNote;
|
||||
|
||||
const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote, force = false) => {
|
||||
let noteLockKey = null;
|
||||
if (isNoteLockEnabled() && NoteLockNote.isLocked(n)) {
|
||||
// The session can lock between the gated load and here (e.g. lock-on-switch) - drop
|
||||
// the load rather than show plaintext that pending saves could no longer encrypt.
|
||||
if (!NoteLockSession.instance().isUnlocked()) return null;
|
||||
// Capture the key with the plaintext so pending saves can re-encrypt even after
|
||||
// the session locks.
|
||||
noteLockKey = NoteLockSession.instance().decryptedKey();
|
||||
}
|
||||
|
||||
const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote) => {
|
||||
let originalCss = '';
|
||||
|
||||
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
|
||||
@@ -268,9 +188,6 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
hasChanged: false,
|
||||
user_updated_time: n.user_updated_time,
|
||||
encryption_applied: n.encryption_applied,
|
||||
is_locked: n.is_locked,
|
||||
noteLockKey,
|
||||
isDecrypted: 'isDecrypted' in n && !!n.isDecrypted,
|
||||
};
|
||||
|
||||
logger.debug('Initializing note state');
|
||||
@@ -283,7 +200,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
|
||||
// If the user changes the note while resources are loading, this can lead to
|
||||
// a note being incorrectly marked as "unchanged".
|
||||
if (!isNewNote && formNoteRef.current?.hasChanged && !force) {
|
||||
if (!isNewNote && formNoteRef.current?.hasChanged) {
|
||||
logger.info('Cancelled note refresh -- form note changed while loading attached resources.');
|
||||
return null;
|
||||
}
|
||||
@@ -298,16 +215,10 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
return newFormNote;
|
||||
}, []);
|
||||
|
||||
const clearFormNote = useCallback(() => {
|
||||
formNoteRef.current = defaultFormNote();
|
||||
setFormNote(formNoteRef.current);
|
||||
}, []);
|
||||
|
||||
useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, clearFormNote, builtInEditorVisible, noteLockSessionUnlocked, setDecryptFailed, setLoadBlocked, onReloadInProgressChange, editorNoteReloadTimeRequest);
|
||||
useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, builtInEditorVisible);
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId) {
|
||||
setDecryptFailed(false);
|
||||
if (formNote.id) setFormNote(defaultFormNote());
|
||||
return () => {};
|
||||
}
|
||||
@@ -333,21 +244,8 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
}
|
||||
|
||||
async function loadNote() {
|
||||
setDecryptFailed(false);
|
||||
const { note: n, blocked, decryptFailed } = await loadNoteForForm(noteId);
|
||||
const n = await Note.load(noteId);
|
||||
if (cancelled) return;
|
||||
|
||||
setLoadBlocked(blocked);
|
||||
setDecryptFailed(decryptFailed);
|
||||
if (blocked || decryptFailed) {
|
||||
await onBeforeLoad({ formNote });
|
||||
if (cancelled) return;
|
||||
// Drop the previous form so no note content lingers behind the unlock or
|
||||
// cannot-decrypt panel. The id guard stops the resulting effect re-run from looping.
|
||||
if (formNoteRef.current.id) clearFormNote();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!n) throw new Error(`Cannot find note with ID: ${noteId}`);
|
||||
logger.debug('Loaded note:', n);
|
||||
|
||||
@@ -429,7 +327,5 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
formNote,
|
||||
setFormNote: onSetFormNote,
|
||||
resourceInfos,
|
||||
decryptFailed: !!noteId && decryptFailedId === noteId,
|
||||
loadBlocked: !!noteId && loadBlockedId === noteId,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { RefObject, useCallback, useRef } from 'react';
|
||||
import { RefObject, useCallback } from 'react';
|
||||
import { FormNote, NoteBodyEditorRef } from './types';
|
||||
import { formNoteToNote } from '.';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
@@ -7,44 +7,27 @@ import Note from '@joplin/lib/models/Note';
|
||||
import type { Dispatch } from 'redux';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
import type { OnSetFormNote } from './useFormNote';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
|
||||
const logger = Logger.create('useScheduleSaveCallbacks');
|
||||
|
||||
interface Props {
|
||||
setFormNote: RefObject<OnSetFormNote>;
|
||||
formNote: RefObject<FormNote>;
|
||||
editorId: string;
|
||||
dispatch: Dispatch;
|
||||
editorRef: RefObject<NoteBodyEditorRef>;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
}
|
||||
|
||||
const useScheduleSaveCallbacks = (props: Props) => {
|
||||
const editorNoteReloadTimeRequestRef = useRef(props.editorNoteReloadTimeRequest);
|
||||
editorNoteReloadTimeRequestRef.current = props.editorNoteReloadTimeRequest;
|
||||
|
||||
const scheduleSaveNote = useCallback((formNote: FormNote) => {
|
||||
if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check
|
||||
|
||||
// reg.logger().debug('Scheduling...', formNote);
|
||||
|
||||
const editorNoteReloadTimeRequest = editorNoteReloadTimeRequestRef.current;
|
||||
const makeAction = (formNote: FormNote) => {
|
||||
return async function() {
|
||||
if (editorNoteReloadTimeRequestRef.current > editorNoteReloadTimeRequest) return;
|
||||
|
||||
// The lock state may change between scheduling and execution (e.g. encryption enabled
|
||||
// from the note list menu), so the save uses the latest form state for this note.
|
||||
const latestFormNote = props.formNote.current?.id === formNote.id ? props.formNote.current : formNote;
|
||||
let note;
|
||||
if (isNoteLockEnabled()) {
|
||||
note = await formNoteToNote({ ...formNote, is_locked: latestFormNote.is_locked, isDecrypted: latestFormNote.isDecrypted });
|
||||
} else {
|
||||
note = await formNoteToNote(formNote);
|
||||
}
|
||||
logger.debug('Saving note...', isNoteLockEnabled() && note.is_locked ? note.id : note);
|
||||
const savedNote = await Note.save(note, { changeId: `editorChange-${props.editorId}`, useNoteLock: true, noteLockKey: latestFormNote.noteLockKey });
|
||||
const note = await formNoteToNote(formNote);
|
||||
logger.debug('Saving note...', note);
|
||||
const savedNote = await Note.save(note, { changeId: `editorChange-${props.editorId}` });
|
||||
|
||||
props.setFormNote.current((prev: FormNote) => {
|
||||
return { ...prev, user_updated_time: savedNote.user_updated_time, hasChanged: false };
|
||||
@@ -63,7 +46,7 @@ const useScheduleSaveCallbacks = (props: Props) => {
|
||||
|
||||
formNote.saveActionQueue.push(makeAction(formNote));
|
||||
return formNote.saveActionQueue.waitForAllDone();
|
||||
}, [props.dispatch, props.editorId, props.setFormNote, props.formNote]);
|
||||
}, [props.dispatch, props.editorId, props.setFormNote]);
|
||||
|
||||
const saveNoteIfWillChange = useCallback(async (formNote: FormNote) => {
|
||||
if (!formNote.id || !formNote.bodyWillChangeId || !props.editorRef.current) return;
|
||||
@@ -76,7 +59,6 @@ const useScheduleSaveCallbacks = (props: Props) => {
|
||||
bodyWillChangeId: 0,
|
||||
bodyChangeId: 0,
|
||||
});
|
||||
await formNote.saveActionQueue.processAllNow();
|
||||
}, [scheduleSaveNote, props.editorRef]);
|
||||
|
||||
return { saveNoteIfWillChange, scheduleSaveNote };
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { runWithFakeTimers, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import useSearchMarkers from './useSearchMarkers';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import { setUpSemanticSearch, tearDownSemanticSearch, updateSearchIndex } from '@joplin/lib/testing/ai/semanticSearch';
|
||||
|
||||
|
||||
describe('useSearchMarkers', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setUpSemanticSearch();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await tearDownSemanticSearch();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'should return markers for semantic search results',
|
||||
noteBody: 'example example example example',
|
||||
searchQuery: 'examples example example example',
|
||||
expected: [{ type: 'text', value: 'example example example example' }],
|
||||
},
|
||||
{
|
||||
label: 'should exclude Markdown from keywords',
|
||||
noteBody: '*test-test-test-test*! test',
|
||||
// cSpell:disable
|
||||
searchQuery: 'testt-test-test-test',
|
||||
// cSpell:enable
|
||||
expected: [{ type: 'text', value: 'test-test-test-test' }],
|
||||
},
|
||||
])('should return keywords for semantic search matches: $label', async ({ noteBody, searchQuery, expected }) => {
|
||||
const note = await Note.save({ title: 'test', body: noteBody });
|
||||
await updateSearchIndex();
|
||||
|
||||
await runWithFakeTimers(async () => {
|
||||
const searchResults = await SearchEngine.instance().search(searchQuery);
|
||||
const test = renderHook(() => useSearchMarkers({
|
||||
showLocalSearch: false,
|
||||
localSearchMarkerOptions: () => null,
|
||||
noteId: note.id,
|
||||
searchResults,
|
||||
searchId: '1',
|
||||
searches: [{ id: '1', query_pattern: searchQuery, query_type: 1, type_: -1, title: searchQuery }],
|
||||
highlightedWords: [],
|
||||
noteTitle: note.title,
|
||||
}));
|
||||
|
||||
await act(() => jest.advanceTimersByTimeAsync(5 * Second));
|
||||
|
||||
await waitFor(() => {
|
||||
const result = test.result.current;
|
||||
expect(result.keywords).toMatchObject(expected);
|
||||
}, { timeout: 30 * Second });
|
||||
test.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,9 +1,4 @@
|
||||
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
|
||||
import { HighlightedWord, SearchEntry } from '@joplin/lib/reducer';
|
||||
import SearchService, { SearchResult } from '@joplin/lib/services/ai/SearchService';
|
||||
import { ProcessResultsRow, SearchType } from '@joplin/lib/services/search/SearchEngine';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface SearchMarkersOptions {
|
||||
searchTimestamp: number;
|
||||
@@ -12,9 +7,8 @@ interface SearchMarkersOptions {
|
||||
withSelection?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export interface SearchMarkers {
|
||||
keywords: HighlightedWord[];
|
||||
keywords: { value: string; type?: string; accuracy?: string }[];
|
||||
options: SearchMarkersOptions;
|
||||
}
|
||||
|
||||
@@ -29,116 +23,16 @@ function defaultSearchMarkers(): SearchMarkers {
|
||||
};
|
||||
}
|
||||
|
||||
// The indexer can prepend the note title to the first chunk:
|
||||
// Remove it so that the results are exact substrings of the note body
|
||||
const removeNoteTitle = (result: SearchResult, noteTitle: string) => {
|
||||
if (result.chunkIndex !== 0) return result;
|
||||
|
||||
let chunkText = result.chunkText.trim();
|
||||
while (chunkText.startsWith(`${noteTitle}\n`)) {
|
||||
chunkText = chunkText.substring(noteTitle.length).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
chunkText,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseSemanticSearchMatchesProps {
|
||||
query: string;
|
||||
noteId: string;
|
||||
noteTitle: string;
|
||||
}
|
||||
|
||||
// Returns matching substrings of the note for the given search query
|
||||
const useSemanticSearchMatches = ({ query, noteId, noteTitle }: UseSemanticSearchMatchesProps) => {
|
||||
const [matchingChunks, setMatchingChunks] = useState<string[]>([]);
|
||||
const matchingChunksRef = useRef(matchingChunks);
|
||||
matchingChunksRef.current = matchingChunks;
|
||||
|
||||
useEffect(() => {
|
||||
if (!query && matchingChunksRef.current.length !== 0) {
|
||||
setMatchingChunks([]);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const noteTitleRef = useRef(noteTitle);
|
||||
noteTitleRef.current = noteTitle;
|
||||
|
||||
useQueuedAsyncEffect(async (event) => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await SearchService.instance().search({
|
||||
query: { text: query }, scope: { type: 'note', noteId }, relevance: 'strict',
|
||||
});
|
||||
if (event.cancelled) return;
|
||||
|
||||
const matches = [];
|
||||
for (const result of results) {
|
||||
const bestMatch = await SearchService.instance().bestMatchInResult(
|
||||
query, removeNoteTitle(result, noteTitleRef.current),
|
||||
);
|
||||
if (event.cancelled) return;
|
||||
matches.push(bestMatch);
|
||||
}
|
||||
|
||||
setMatchingChunks(matches);
|
||||
}, [query, noteId], { interval: Second });
|
||||
|
||||
return matchingChunks;
|
||||
};
|
||||
|
||||
interface UseSearchMarkersProps {
|
||||
showLocalSearch: boolean;
|
||||
localSearchMarkerOptions: ()=> SearchMarkers;
|
||||
noteId: string;
|
||||
searchResults: ProcessResultsRow[];
|
||||
searchId: string;
|
||||
searches: SearchEntry[];
|
||||
highlightedWords: HighlightedWord[];
|
||||
noteTitle: string;
|
||||
}
|
||||
|
||||
export default function useSearchMarkers({
|
||||
showLocalSearch,
|
||||
localSearchMarkerOptions,
|
||||
noteId,
|
||||
searchResults,
|
||||
searchId,
|
||||
searches,
|
||||
highlightedWords,
|
||||
noteTitle,
|
||||
}: UseSearchMarkersProps) {
|
||||
const currentNoteSearchResult = useMemo(() => {
|
||||
return searchResults.find(result => result.id === noteId);
|
||||
}, [searchResults, noteId]);
|
||||
|
||||
const semanticSearchQuery = useMemo(() => {
|
||||
if (!currentNoteSearchResult) return null;
|
||||
if (!currentNoteSearchResult.searchType.includes(SearchType.Semantic)) return null;
|
||||
const search = searches.find(search => search.id === searchId);
|
||||
return search.query_pattern;
|
||||
}, [searches, searchId, currentNoteSearchResult]);
|
||||
|
||||
const semanticSearchMatches = useSemanticSearchMatches({
|
||||
query: semanticSearchQuery, noteId, noteTitle,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-explicit-any -- Function type matches the original signature; any[] matches the lib reducer's searches shape and the heterogeneous highlightedWords shape (string[] at the call site, keyword shapes inside)
|
||||
export default function useSearchMarkers(showLocalSearch: boolean, localSearchMarkerOptions: Function, searches: any[], selectedSearchId: string, highlightedWords: any[] = []) {
|
||||
return useMemo((): SearchMarkers => {
|
||||
if (showLocalSearch) return localSearchMarkerOptions();
|
||||
|
||||
const output = defaultSearchMarkers();
|
||||
output.keywords = highlightedWords ?? [];
|
||||
|
||||
if (semanticSearchMatches.length) {
|
||||
output.keywords = output.keywords.concat(semanticSearchMatches.map(match => (
|
||||
{ type: 'text', accuracy: 'partial', value: match, source: 'semantic' }
|
||||
)));
|
||||
}
|
||||
output.keywords = highlightedWords;
|
||||
|
||||
return output;
|
||||
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, semanticSearchMatches]);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, searches, selectedSearchId]);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ type Props = {
|
||||
makeItemIndexVisible: (index: number)=> void;
|
||||
};
|
||||
|
||||
describe('UseAutoScroll', () => {
|
||||
describe('useAutoScroll', () => {
|
||||
|
||||
test('scrolls to the note when a new note is selected', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
@@ -4,7 +4,6 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { Dispatch } from 'redux';
|
||||
import { EventEmitter } from 'events';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
export interface Props {
|
||||
themeId: number;
|
||||
@@ -28,7 +27,7 @@ export interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- searches: any[] is the shape in lib's reducer; tightening requires updating the reducer first
|
||||
searches: any[];
|
||||
selectedSearchId: string;
|
||||
highlightedWords: HighlightedWord[];
|
||||
highlightedWords: string[];
|
||||
provisionalNoteIds: string[];
|
||||
visible: boolean;
|
||||
focusedField: string;
|
||||
|
||||
@@ -7,20 +7,14 @@ import useItemElement from './utils/useItemElement';
|
||||
import { ItemEventHandlers, OnInputChange } from './utils/types';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import bridge from '../../services/bridge';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useRenderedNote from './utils/useRenderedNote';
|
||||
import { Dispatch } from 'redux';
|
||||
import getNoteElementIdFromJoplinId from './utils/getNoteElementIdFromJoplinId';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
interface NoteItemProps {
|
||||
dragIndex: number;
|
||||
flow: ItemFlow;
|
||||
highlightedWords: HighlightedWord[];
|
||||
highlightedWords: string[];
|
||||
index: number;
|
||||
isProvisional: boolean;
|
||||
itemSize: Size;
|
||||
@@ -63,15 +57,6 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
};
|
||||
|
||||
if (changeEvent.elementId === 'todo-checkbox') {
|
||||
if (isNoteLockEnabled()) {
|
||||
const lockState = await Note.load(changeEvent.noteId, { fields: ['is_locked'] });
|
||||
if (NoteLockNote.isLocked(lockState) && !NoteLockSession.instance().isUnlocked()) {
|
||||
// event.currentTarget is already cleared here, after the await - target is the same input
|
||||
event.target.checked = !changeEvent.value;
|
||||
bridge().showErrorMessageBox(_('Cannot change a locked note while the session is locked'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
await Note.save({
|
||||
id: changeEvent.noteId,
|
||||
todo_completed: changeEvent.value ? Date.now() : 0,
|
||||
|
||||
@@ -2,9 +2,8 @@ import { htmlentities } from '@joplin/utils/html';
|
||||
const Mark = require('mark.js/dist/mark.min.js');
|
||||
const markJsUtils = require('@joplin/lib/markJsUtils');
|
||||
import { replaceRegexDiacritics, pregQuote } from '@joplin/lib/string-utils';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
const getNoteTitleHtml = (highlightedWords: HighlightedWord[], displayTitle: string) => {
|
||||
const getNoteTitleHtml = (highlightedWords: string[], displayTitle: string) => {
|
||||
if (highlightedWords.length) {
|
||||
const titleElement = document.createElement('span');
|
||||
titleElement.textContent = displayTitle;
|
||||
|
||||
@@ -11,7 +11,6 @@ import prepareViewProps from './prepareViewProps';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import { unique } from '@joplin/lib/array';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
interface RenderedNote {
|
||||
id: string;
|
||||
@@ -23,7 +22,7 @@ const hashContent = (content: unknown) => {
|
||||
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
|
||||
};
|
||||
|
||||
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: HighlightedWord[], itemIndex: number, columns: NoteListColumns) => {
|
||||
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: string[], itemIndex: number, columns: NoteListColumns) => {
|
||||
const [renderedNote, setRenderedNote] = useState<RenderedNote>(null);
|
||||
|
||||
let dependencies = columns && columns.length ? columns.map(c => c.name) as ListRendererDependency[] : [];
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
|
||||
import Dialog from '@joplin/lib/components/Dialog';
|
||||
import DialogTitle from '../DialogTitle';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import LabelledPasswordInput from '../PasswordInput/LabelledPasswordInput';
|
||||
|
||||
interface SuccessCommand {
|
||||
name: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
dispatch: Dispatch;
|
||||
successCommand?: SuccessCommand;
|
||||
}
|
||||
|
||||
export default function(props: Props) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
props.dispatch({
|
||||
type: 'DIALOG_CLOSE',
|
||||
name: 'noteLockUnlock',
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const onPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(event.target.value);
|
||||
}, []);
|
||||
|
||||
const onButtonRowClick = useCallback(async (event: ClickEvent) => {
|
||||
if (event.buttonName === 'cancel') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.buttonName === 'ok') {
|
||||
setUnlocking(true);
|
||||
try {
|
||||
await NoteLockSession.instance().unlock(password);
|
||||
} catch (error) {
|
||||
// WebCrypto reports a wrong password as a generic OperationError.
|
||||
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
|
||||
setUnlocking(false);
|
||||
return;
|
||||
}
|
||||
setUnlocking(false);
|
||||
onClose();
|
||||
if (props.successCommand) {
|
||||
void CommandService.instance().execute(props.successCommand.name, ...props.successCommand.args);
|
||||
}
|
||||
}
|
||||
}, [password, onClose, props.successCommand]);
|
||||
|
||||
return (
|
||||
<Dialog onCancel={onClose} className="note-lock-unlock-dialog">
|
||||
<div className="dialog-root">
|
||||
<DialogTitle title={_('Unlock notes')}/>
|
||||
<div className="dialog-content">
|
||||
<p>{_('Enter your password to unlock your notes for this session.')}</p>
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Note lock password')}
|
||||
value={password}
|
||||
onChange={onPasswordChange}
|
||||
/>
|
||||
{!!errorMessage && <p className="error-message" role="alert">{errorMessage}</p>}
|
||||
</div>
|
||||
<DialogButtonRow
|
||||
themeId={props.themeId}
|
||||
onClick={onButtonRowClick}
|
||||
okButtonLabel={_('Unlock')}
|
||||
okButtonDisabled={!password || unlocking}
|
||||
cancelButtonDisabled={unlocking}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,6 @@ export interface NoteViewerControl {
|
||||
focusLine(editorLine: number): void;
|
||||
focus(): void;
|
||||
hasFocus(): boolean;
|
||||
stopMediaPlayback(): void;
|
||||
}
|
||||
|
||||
const usePluginMessageResponder = (webviewRef: RefObject<HTMLIFrameElement>) => {
|
||||
@@ -144,10 +143,6 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
|
||||
if (channel === 'setMarkers') {
|
||||
win.postMessage({ target: 'webview', name: 'setMarkers', data: { keywords: arg0, options: arg1 } }, '*');
|
||||
}
|
||||
|
||||
if (channel === 'stopMediaPlayback') {
|
||||
win.postMessage({ target: 'webview', name: 'stopMediaPlayback', data: {} }, '*');
|
||||
}
|
||||
},
|
||||
focus: () => {
|
||||
if (webviewRef.current) {
|
||||
@@ -173,9 +168,6 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
stopMediaPlayback: () => {
|
||||
result.send('stopMediaPlayback');
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}, [parentDoc]);
|
||||
|
||||
@@ -35,7 +35,7 @@ const PasswordInput = (props: Props) => {
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<button type="button" onClick={onShowPassword} className="showpasswordbutton">
|
||||
<button onClick={onShowPassword} className="showpasswordbutton">
|
||||
<i className={icon} role='img' aria-label={title} title={title}></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
> .showpasswordbutton {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import PromptDialog from './PromptDialog';
|
||||
|
||||
describe('PromptDialog', () => {
|
||||
describe('PromptDialog Escape key handling', () => {
|
||||
|
||||
const setupKeyHandler = (inputType: string, menuIsOpened: boolean) => {
|
||||
const onCloseMock = jest.fn();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import DialogButtonRow, { ClickEvent } from './DialogButtonRow';
|
||||
import Dialog from '@joplin/lib/components/Dialog';
|
||||
import DialogTitle from './DialogTitle';
|
||||
@@ -17,7 +15,6 @@ import { clipboard } from 'electron';
|
||||
import useOnPublishFolderLinkClick from '@joplin/lib/components/shared/PublishFolderDialog/useOnPublishFolderLinkClick';
|
||||
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
|
||||
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
|
||||
import Button from './Button/Button';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -31,7 +28,6 @@ export function PublishFolderDialog(props: Props) {
|
||||
const [folder, setFolder] = useState<FolderEntity>(null);
|
||||
const [noteCount, setNoteCount] = useState<number>(0);
|
||||
const [publishFolderStatus, setPublishFolderStatus] = useState<SharingStatus>(SharingStatus.Unknown);
|
||||
const [isUnpublishing, setIsUnpublishing] = useState(false);
|
||||
|
||||
const encryptionWarning = useEncryptionWarningMessage();
|
||||
const publishedShare = useMemo(() => {
|
||||
@@ -72,37 +68,19 @@ export function PublishFolderDialog(props: Props) {
|
||||
props.onClose();
|
||||
}, [props]);
|
||||
|
||||
const onUnpublishFolderClick = useCallback(async () => {
|
||||
setIsUnpublishing(true);
|
||||
try {
|
||||
await ShareService.instance().unpublishFolder(props.folderId);
|
||||
} catch (error) {
|
||||
void shim.showErrorDialog(JoplinServerApi.connectionErrorMessage(error as Error));
|
||||
} finally {
|
||||
setIsUnpublishing(false);
|
||||
}
|
||||
}, [props.folderId]);
|
||||
|
||||
const statusMessage = useShareStatusMessage({ sharesState: publishFolderStatus, noteCount: 1 });
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<div className="form share-note-dialog">
|
||||
<DialogTitle title={_('Publish Notebook')}/>
|
||||
<div className="shared-note-list-item">
|
||||
<div className="title">
|
||||
<div className="folder-info">
|
||||
<p>{_('Notebook: %s', folder ? folder.title : '...')}</p>
|
||||
<p>{_('Status: %s', publishedShare ? _('Published') : _('Not published'))}</p>
|
||||
<p>{_n('%d note in notebook', '%d notes in notebook', noteCount, noteCount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{publishedShare && (
|
||||
<Button disabled={isUnpublishing} tooltip={_('Unpublish notebook')} iconName="fas fa-unlink" onClick={onUnpublishFolderClick}/>
|
||||
)}
|
||||
<div className="folder-info">
|
||||
<p>{_('Notebook: %s', folder ? folder.title : '...')}</p>
|
||||
<p>{_('Status: %s', publishedShare ? _('Published') : _('Not published'))}</p>
|
||||
<p>{_n('%d note in notebook', '%d notes in notebook', noteCount, noteCount)}</p>
|
||||
</div>
|
||||
<button
|
||||
disabled={isUnpublishing || !folder || [SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(publishFolderStatus) >= 0}
|
||||
disabled={!folder || [SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(publishFolderStatus) >= 0}
|
||||
className="share"
|
||||
onClick={publishFolderLinkButton_click}
|
||||
>{publishedShare ? _('Copy Shareable Link') : _('Publish notebook')}</button>
|
||||
|
||||
@@ -34,35 +34,43 @@ const LayoutItemContainer: React.FC<Props> = ({
|
||||
const canResizeBottom = parentDir === LayoutItemDirection.Column && !isLastChild;
|
||||
|
||||
const className = `resizableLayoutItem rli-${item.key}`;
|
||||
const enable = {
|
||||
top: false,
|
||||
right: canResizeRight,
|
||||
bottom: canResizeBottom,
|
||||
left: false,
|
||||
topRight: false,
|
||||
bottomRight: false,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
};
|
||||
if (canResizeRight || canResizeBottom) {
|
||||
const enable = {
|
||||
top: false,
|
||||
right: canResizeRight,
|
||||
bottom: canResizeBottom,
|
||||
left: false,
|
||||
topRight: false,
|
||||
bottomRight: false,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
key={item.key}
|
||||
className={className}
|
||||
style={style}
|
||||
size={size}
|
||||
onResizeStart={onResizeStart}
|
||||
onResize={onResize}
|
||||
onResizeStop={onResizeStop}
|
||||
enable={enable}
|
||||
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
|
||||
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
|
||||
maxWidth={resizedItemMaxSize?.width}
|
||||
maxHeight={resizedItemMaxSize?.height}
|
||||
>
|
||||
{children}
|
||||
</Resizable>
|
||||
);
|
||||
return (
|
||||
<Resizable
|
||||
key={item.key}
|
||||
className={className}
|
||||
style={style}
|
||||
size={size}
|
||||
onResizeStart={onResizeStart}
|
||||
onResize={onResize}
|
||||
onResizeStop={onResizeStop}
|
||||
enable={enable}
|
||||
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
|
||||
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
|
||||
maxWidth={resizedItemMaxSize?.width}
|
||||
maxHeight={resizedItemMaxSize?.height}
|
||||
>
|
||||
{children}
|
||||
</Resizable>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={item.key} className={className} style={{ ...style, ...size }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default LayoutItemContainer;
|
||||
@@ -33,7 +33,6 @@ import SsoLoginScreen from './SsoLoginScreen/SsoLoginScreen';
|
||||
import SamlShared from '@joplin/lib/components/shared/SamlShared';
|
||||
import PopupNotificationProvider from './PopupNotification/PopupNotificationProvider';
|
||||
import { ThemeProvider, StyleSheetManager, createGlobalStyle } from 'styled-components';
|
||||
import QuitSyncDialog from './QuitSyncDialog';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -179,7 +178,6 @@ class RootComponent extends React.Component<Props, any> {
|
||||
<MenuBar/>
|
||||
<GlobalStyle/>
|
||||
<WindowCommandsAndDialogs windowId={defaultWindowId} />
|
||||
<QuitSyncDialog themeId={this.props.themeId} />
|
||||
<Navigator style={navigatorStyle} screens={screens} className={`profile-${this.props.profileConfigCurrentProfileId}`} />
|
||||
{this.renderSecondaryWindows()}
|
||||
{this.renderModalMessage(this.modalDialogProps())}
|
||||
|
||||
@@ -170,6 +170,7 @@ function ShareFolderDialog(props: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
setRecipientSuggestions([]);
|
||||
setLatestError(null);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -244,9 +245,7 @@ function ShareFolderDialog(props: Props) {
|
||||
ShareService.instance().refreshShares(),
|
||||
ShareService.instance().refreshShareUsers(share.id),
|
||||
]);
|
||||
// Only clear the input on success, otherwise the user loses the email
|
||||
// they typed and the error message would be wiped by the suggestions effect.
|
||||
if (!errorSet) setRecipientEmail('');
|
||||
setRecipientEmail('');
|
||||
|
||||
await synchronize();
|
||||
} catch (error) {
|
||||
@@ -258,7 +257,6 @@ function ShareFolderDialog(props: Props) {
|
||||
|
||||
const recipientEmail_change = useCallback((value: string) => {
|
||||
setRecipientEmail(value);
|
||||
setLatestError(null);
|
||||
}, []);
|
||||
|
||||
const renderRecipientSuggestion = useCallback((suggestedValue: string) => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import onUnshareNoteClick from '@joplin/lib/components/shared/ShareNoteDialog/on
|
||||
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
|
||||
import useEncryptionWarningMessage from '@joplin/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage';
|
||||
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
|
||||
import useIsPublished from '@joplin/lib/components/shared/ShareNoteDialog/useIsPublished';
|
||||
import { clipboard } from 'electron';
|
||||
|
||||
interface Props {
|
||||
@@ -67,10 +66,22 @@ export function ShareNoteDialog(props: Props) {
|
||||
recursiveShare,
|
||||
});
|
||||
|
||||
const renderNote = (note: NoteEntity) => {
|
||||
const unshareButton = !props.shares.find(s => s.note_id === note.id) ? null : (
|
||||
<Button tooltip={_('Unpublish note')} iconName="fas fa-share-alt" onClick={() => onUnshareNoteClick({ noteId: note.id })}/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={note.id} className='shared-note-list-item'>
|
||||
<span className='title'>{note.title}</span>{unshareButton}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNoteList = (notes: NoteEntity[]) => {
|
||||
const noteComps = [];
|
||||
for (const note of notes) {
|
||||
noteComps.push(<NoteItem key={note.id} note={note} shares={props.shares}/>);
|
||||
noteComps.push(renderNote(note));
|
||||
}
|
||||
return <div className="notes">{noteComps}</div>;
|
||||
};
|
||||
@@ -125,25 +136,6 @@ export function ShareNoteDialog(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
interface NoteItemProps {
|
||||
note: NoteEntity;
|
||||
shares: StateShare[];
|
||||
}
|
||||
|
||||
const NoteItem: React.FC<NoteItemProps> = ({ note, shares }) => {
|
||||
const published = useIsPublished(note, shares);
|
||||
|
||||
const unshareButton = published && (
|
||||
<Button tooltip={_('Unpublish note')} iconName='fas fa-unlink' 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,15 +32,13 @@ const SidebarComponent = (props: Props) => {
|
||||
const renderSynchronizeButton = (type: string) => {
|
||||
const label = type === 'sync' ? _('Synchronise') : _('Cancel');
|
||||
const nothingToSync = type === 'sync' && !props.syncPending && syncCompletedWithoutError(props.syncReport);
|
||||
const syncHasErrors = type === 'sync' && !props.syncPending && !!props.syncReport.errors?.length;
|
||||
const iconName = syncHasErrors ? 'fas fa-exclamation-triangle' : nothingToSync ? 'fas fa-check' : 'icon-sync';
|
||||
const iconName = nothingToSync ? 'fas fa-check' : 'icon-sync';
|
||||
|
||||
return (
|
||||
<StyledSynchronizeButton
|
||||
level={ButtonLevel.SidebarSecondary}
|
||||
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'} ${nothingToSync ? '-synced' : ''} ${syncHasErrors ? '-error' : ''}`}
|
||||
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'} ${nothingToSync ? '-synced' : ''}`}
|
||||
iconName={iconName}
|
||||
iconLabel={syncHasErrors ? _('Synchronisation error') : undefined}
|
||||
key="sync_button"
|
||||
title={label}
|
||||
onClick={() => {
|
||||
|
||||
@@ -38,10 +38,6 @@
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
&.-error > .icon {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
&:not(.-syncing):not(.-synced) > .icon {
|
||||
animation: icon-fade-in-b 300ms ease-in-out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import { clipboard } from 'electron';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'copyNoteExternalLink',
|
||||
label: () => _('Copy external link'),
|
||||
iconName: 'fas fa-link',
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, noteId: string = null) => {
|
||||
noteId = noteId || stateUtils.selectedNoteId(context.state);
|
||||
if (!noteId) return;
|
||||
clipboard.writeText(getNoteCallbackUrl(noteId));
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected',
|
||||
};
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import { disableNoteLock } from '@joplin/lib/services/noteLock/setNoteLockState';
|
||||
import bridge from '../../../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'disableNoteEncryption',
|
||||
label: () => _('Remove note lock'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, noteId: string = null) => {
|
||||
if (!isNoteLockEnabled()) return;
|
||||
if (noteId === null) {
|
||||
if (context.state.selectedNoteIds.length !== 1) return;
|
||||
noteId = context.state.selectedNoteIds[0];
|
||||
}
|
||||
|
||||
// Same pending-save guard as enableNoteEncryption.
|
||||
if (context.state.editorNoteStatuses[noteId] === 'saving') {
|
||||
bridge().showErrorMessageBox(_('This note is currently being saved. Please try again in a moment.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await disableNoteLock(noteId);
|
||||
} catch (error) {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && noteIsLocked && noteLockSessionUnlocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder',
|
||||
};
|
||||
};
|
||||
@@ -19,6 +19,6 @@ export const runtime = (): CommandRuntime => {
|
||||
});
|
||||
}
|
||||
},
|
||||
enabledCondition: '!noteIsReadOnlyShare',
|
||||
enabledCondition: '!noteIsReadOnly',
|
||||
};
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { DesktopCommandContext } from '../../../services/commands/types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import { enableNoteLock } from '@joplin/lib/services/noteLock/setNoteLockState';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
import bridge from '../../../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'enableNoteEncryption',
|
||||
label: () => _('Lock this note'),
|
||||
};
|
||||
|
||||
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(_('Locking a note requires the note lock password, which has not been set up yet. 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',
|
||||
};
|
||||
};
|
||||