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
|
||||
|
||||
@@ -8,22 +8,6 @@ function Install-WindowsDeps {
|
||||
$env:BUILD_SEQUENCIAL = '1'
|
||||
$env:IS_CONTINUOUS_INTEGRATION = '1'
|
||||
|
||||
if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') {
|
||||
# Deliberately not setting npm_config_build_from_source to force the
|
||||
# sqlite3 source build: sharp treats that variable as a boolean and
|
||||
# would then also build libvips from source, which fails. sqlite3
|
||||
# installs with --fallback-to-build, so it compiles by itself when the
|
||||
# win32-arm64 prebuilt is missing.
|
||||
|
||||
# Deliberately not installing the VSSetup PowerShell module here: with it
|
||||
# present, node-gyp probes Visual Studio via `Get-VSSetupInstance |
|
||||
# ConvertTo-Json`, whose output exceeds the default 1MB child process
|
||||
# buffer on the VS 2026 image. That fails with
|
||||
# ERR_CHILD_PROCESS_STDIO_MAXBUFFER and node-gyp then reports the
|
||||
# install as version "undefined". Without the module it falls through to
|
||||
# its own Find-VisualStudio.cs helper, which prints far less.
|
||||
}
|
||||
|
||||
$attempts = 3
|
||||
for ($i = 1; $i -le $attempts; $i++) {
|
||||
yarn install
|
||||
@@ -36,32 +20,12 @@ function Install-WindowsDeps {
|
||||
|
||||
# Install dependencies and run `yarn dist` for the Windows desktop app.
|
||||
# Extra args are forwarded to `yarn dist` (e.g. --publish=never).
|
||||
#
|
||||
# Each runner builds only its own arch: cross compiling arm64 from x64 silently
|
||||
# ships x64 .node files, as @electron/rebuild doesn't resolve native modules
|
||||
# hoisted to the workspace root.
|
||||
# https://github.com/electron-userland/electron-builder/issues/10187
|
||||
function Build-WindowsApp {
|
||||
param([string[]]$DistArgs = @())
|
||||
|
||||
Install-WindowsDeps
|
||||
|
||||
if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') {
|
||||
# NSIS gets an `-arm64` suffix on its own, but the portable name is fixed
|
||||
# and the update metadata is `latest.yml` for every Windows arch. Both
|
||||
# would clobber the x64 runner's artifacts.
|
||||
# https://github.com/electron-userland/electron-builder/issues/6372
|
||||
$archArgs = @(
|
||||
'--arm64'
|
||||
'-c.portable.artifactName=${productName}Portable-arm64.${ext}'
|
||||
'-c.publish.provider=github'
|
||||
'-c.publish.channel=latest-win-arm64'
|
||||
)
|
||||
} else {
|
||||
$archArgs = @('--x64', '--ia32')
|
||||
}
|
||||
|
||||
cd packages/app-desktop
|
||||
yarn dist @archArgs @DistArgs
|
||||
yarn dist @DistArgs
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
@@ -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]+: )?.+`);
|
||||
|
||||
@@ -32,12 +32,3 @@ docker manifest annotate $SERVER_REPOSITORY:$VERSION $SERVER_REPOSITORY:arm64-$V
|
||||
docker manifest annotate $SERVER_REPOSITORY:$VERSION $SERVER_REPOSITORY:amd64-$VERSION --arch amd64
|
||||
|
||||
docker manifest push $SERVER_REPOSITORY:$VERSION
|
||||
|
||||
docker manifest create $SERVER_REPOSITORY:latest \
|
||||
$SERVER_REPOSITORY:arm64-$VERSION \
|
||||
$SERVER_REPOSITORY:amd64-$VERSION
|
||||
|
||||
docker manifest annotate $SERVER_REPOSITORY:latest $SERVER_REPOSITORY:arm64-$VERSION --arch arm64
|
||||
docker manifest annotate $SERVER_REPOSITORY:latest $SERVER_REPOSITORY:amd64-$VERSION --arch amd64
|
||||
|
||||
docker manifest push $SERVER_REPOSITORY:latest
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Run the test files changed by a pull request several times, to catch flaky
|
||||
# tests before they are merged.
|
||||
#
|
||||
# Usage: repeat_changed_tests.sh <base_ref> [repeat_count]
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
ROOT_DIR="$SCRIPT_DIR/../.."
|
||||
|
||||
BASE_REF="${1:-}"
|
||||
REPEAT_COUNT="${2:-5}"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# The CI checkout is shallow, so the base branch is not present locally. Always
|
||||
# fetch it rather than using a local branch of the same name, which may be
|
||||
# outdated.
|
||||
if git fetch --no-tags --depth=1 origin "$BASE_REF" 2> /dev/null; then
|
||||
BASE_REF=FETCH_HEAD
|
||||
elif ! git rev-parse --verify --quiet "$BASE_REF" > /dev/null; then
|
||||
echo "Could not resolve base ref \"$BASE_REF\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `integration-tests` holds the Playwright specs (see app-desktop/playwright.config.ts),
|
||||
# which Jest cannot run. Other `.spec.*` files are Jest tests, so they are kept.
|
||||
testFiles=$(git diff --name-only --diff-filter=d "$BASE_REF" HEAD | grep -E '^packages/[^/]+/.*\.(test|spec)\.(ts|tsx|js|jsx)$' | grep -v -E '^packages/[^/]+/integration-tests/' || true)
|
||||
|
||||
if [ -z "$testFiles" ]; then
|
||||
echo "No test files were changed - skipping flaky test detection"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changed test files:"
|
||||
echo "$testFiles" | sed 's/^/ /'
|
||||
|
||||
# Each package has its own Jest config and must be run from its own directory.
|
||||
packages=$(echo "$testFiles" | sed -E 's|^packages/([^/]+)/.*|\1|' | sort -u)
|
||||
|
||||
for package in $packages; do
|
||||
packageDir="$ROOT_DIR/packages/$package"
|
||||
|
||||
# Some packages have test files but no test runner configured.
|
||||
if ! node -e "const s = require('$packageDir/package.json').scripts; process.exit(s && s['test-ci'] ? 0 : 1)" 2> /dev/null; then
|
||||
echo "Package $package has no test-ci script - skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
# See packages/app-mobile/tools/runTestsConditionally.js
|
||||
if [ "$package" == "app-mobile" ] && [ "$RUNNER_OS" == "macOS" ]; then
|
||||
echo "Skipping app-mobile tests on macOS"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Pass the file names without their extension: some packages run the
|
||||
# TypeScript sources while others run the compiled files, and Jest matches
|
||||
# these patterns against the full path either way.
|
||||
packageTestNames=$(echo "$testFiles" | grep -E "^packages/$package/" | xargs -n 1 basename | sed -E 's#\.[a-z]+$##')
|
||||
|
||||
cd "$packageDir"
|
||||
for i in $(seq 1 "$REPEAT_COUNT"); do
|
||||
echo "Running $package tests - attempt $i/$REPEAT_COUNT..."
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
if ! yarn jest $packageTestNames --forceExit; then
|
||||
echo "Tests failed on attempt $i/$REPEAT_COUNT in $package - they are flaky if earlier attempts passed"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cd "$ROOT_DIR"
|
||||
done
|
||||
@@ -165,18 +165,6 @@ if [ "$RUN_TESTS" == "1" ]; then
|
||||
if [ $testResult -ne 0 ]; then
|
||||
exit $testResult
|
||||
fi
|
||||
|
||||
# Run the test files changed by the pull request several times, to catch
|
||||
# flaky tests before they are merged.
|
||||
if [ "$IS_PULL_REQUEST" == "1" ]; then
|
||||
echo "Step: Checking changed tests for flakiness..."
|
||||
|
||||
"$SCRIPT_DIR/repeat_changed_tests.sh" "$GITHUB_BASE_REF"
|
||||
testResult=$?
|
||||
if [ $testResult -ne 0 ]; then
|
||||
exit $testResult
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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}'
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Generate GitHub App token
|
||||
if: steps.check-secrets.outputs.available == 'true'
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.CODERABBIT_CLEANUP_APP_ID }}
|
||||
private-key: ${{ secrets.CODERABBIT_CLEANUP_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -10,12 +10,7 @@ jobs:
|
||||
matrix:
|
||||
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
|
||||
# https://github.com/actions/runner-images/issues/6709
|
||||
#
|
||||
# windows-11-vs2026-arm is pinned because windows-11-arm migrates to
|
||||
# Visual Studio 2026 in late September 2026, and that job compiles
|
||||
# sqlite3 from source.
|
||||
# https://github.com/actions/runner-images/issues/14602
|
||||
os: [macos-15-intel, ubuntu-22.04, windows-2025, ubuntu-22.04-arm, windows-11-vs2026-arm]
|
||||
os: [macos-15-intel, ubuntu-22.04, windows-2025, ubuntu-22.04-arm]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -50,7 +45,7 @@ jobs:
|
||||
# Login to Docker only if we're on a server release tag. If we run this on
|
||||
# a pull request it will fail because the PR doesn't have access to
|
||||
# secrets
|
||||
- uses: docker/login-action@v4
|
||||
- uses: docker/login-action@v3
|
||||
if: runner.os == 'Linux' && (startsWith(github.ref, 'refs/tags/server-v') || startsWith(github.ref, 'refs/tags/transcribe-v'))
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -183,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
|
||||
@@ -39,7 +37,7 @@ jobs:
|
||||
cd ${GITHUB_WORKSPACE}/packages/app-desktop/
|
||||
bash ./integration-tests/run-ci.sh
|
||||
# See https://playwright.dev/docs/ci-intro#setting-up-github-actions
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report-${{ matrix.os }}
|
||||
@@ -47,15 +45,13 @@ jobs:
|
||||
retention-days: 7
|
||||
Mobile:
|
||||
if: github.repository == 'laurent22/joplin'
|
||||
runs-on: macos-26
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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
|
||||
@@ -183,7 +177,6 @@ packages/app-desktop/commands/openNoteInNewWindow.js
|
||||
packages/app-desktop/commands/openPrimaryAppInstance.js
|
||||
packages/app-desktop/commands/openProfileDirectory.js
|
||||
packages/app-desktop/commands/openSecondaryAppInstance.js
|
||||
packages/app-desktop/commands/openSyncWizard.js
|
||||
packages/app-desktop/commands/replaceMisspelling.js
|
||||
packages/app-desktop/commands/restoreNoteRevision.js
|
||||
packages/app-desktop/commands/showProfileEditor.js
|
||||
@@ -198,9 +191,7 @@ packages/app-desktop/commands/toggleSafeMode.js
|
||||
packages/app-desktop/commands/toggleTabMovesFocus.js
|
||||
packages/app-desktop/gui/AiDegradedNotice.js
|
||||
packages/app-desktop/gui/Button/Button.js
|
||||
packages/app-desktop/gui/ChatPanel/ChatHistory.js
|
||||
packages/app-desktop/gui/ChatPanel/ChatMessageItem.js
|
||||
packages/app-desktop/gui/ChatPanel/ChatPanel.test.js
|
||||
packages/app-desktop/gui/ChatPanel/ChatPanel.js
|
||||
packages/app-desktop/gui/ClipperConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
@@ -214,7 +205,6 @@ packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/NoteLockSettings.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingButton.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingHeader.js
|
||||
@@ -226,7 +216,6 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/useNoteLockMode.js
|
||||
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
|
||||
packages/app-desktop/gui/ConfigScreen/types.js
|
||||
packages/app-desktop/gui/DialogButtonRow.js
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
||||
packages/app-desktop/gui/DialogTitle.js
|
||||
@@ -258,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
|
||||
@@ -312,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
|
||||
@@ -329,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
|
||||
@@ -378,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
|
||||
@@ -426,9 +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/NoteLockMigrationDialog/Dialog.test.js
|
||||
packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.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,7 +495,6 @@ packages/app-desktop/gui/ToolbarSpace.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotification.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.js
|
||||
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
|
||||
packages/app-desktop/gui/WarningBanner.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
|
||||
@@ -524,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
|
||||
@@ -537,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
|
||||
@@ -545,7 +521,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openAiChatConversation.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
|
||||
@@ -558,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
|
||||
@@ -611,30 +585,21 @@ packages/app-desktop/gui/utils/NoteListUtils.js
|
||||
packages/app-desktop/gui/utils/announceForAccessibility.js
|
||||
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
|
||||
packages/app-desktop/gui/utils/dragAndDrop.js
|
||||
packages/app-desktop/gui/utils/hasNoteLockKey.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/JoplinCloudLoginScreen.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/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen/SyncTab.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
packages/app-desktop/integration-tests/multiWindow.spec.js
|
||||
packages/app-desktop/integration-tests/networking.spec.js
|
||||
packages/app-desktop/integration-tests/noteList.spec.js
|
||||
packages/app-desktop/integration-tests/pluginApi.spec.js
|
||||
packages/app-desktop/integration-tests/resizableLayout.spec.js
|
||||
@@ -710,11 +675,9 @@ packages/app-desktop/utils/customProtocols/registerCustomProtocols.js
|
||||
packages/app-desktop/utils/getAssetPath.js
|
||||
packages/app-desktop/utils/initReact.js
|
||||
packages/app-desktop/utils/initializeCommandService.js
|
||||
packages/app-desktop/utils/initializeNetworkConnectionAttemptTimeout.js
|
||||
packages/app-desktop/utils/isSafeToOpen.test.js
|
||||
packages/app-desktop/utils/isSafeToOpen.js
|
||||
packages/app-desktop/utils/layout/layoutKeyToLabel.js
|
||||
packages/app-desktop/utils/noteLockPrompts.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.js
|
||||
packages/app-desktop/utils/sourceMapSetup.js
|
||||
@@ -732,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
|
||||
@@ -830,11 +792,6 @@ packages/app-mobile/components/ScreenHeader/index.js
|
||||
packages/app-mobile/components/SearchInput.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
packages/app-mobile/components/SideMenu.js
|
||||
packages/app-mobile/components/SideMenuContent/BottomPanelActions.js
|
||||
packages/app-mobile/components/SideMenuContent/FolderItem.js
|
||||
packages/app-mobile/components/SideMenuContent/SideMenuContent.js
|
||||
packages/app-mobile/components/SideMenuContent/SideMenuItem.js
|
||||
packages/app-mobile/components/SideMenuContent/SidebarIcon.js
|
||||
packages/app-mobile/components/SideMenuContentNote.js
|
||||
packages/app-mobile/components/SyncWizard/JoplinCloudIcon.js
|
||||
packages/app-mobile/components/SyncWizard/SyncWizard.js
|
||||
@@ -867,7 +824,6 @@ packages/app-mobile/components/plugins/PluginNotification.js
|
||||
packages/app-mobile/components/plugins/PluginRunner.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.test.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/pluginRunnerBackgroundPage.js
|
||||
@@ -900,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
|
||||
@@ -945,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
|
||||
@@ -953,8 +906,6 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
|
||||
packages/app-mobile/components/screens/Note/commands/setTags.js
|
||||
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
|
||||
packages/app-mobile/components/screens/Note/types.js
|
||||
packages/app-mobile/components/screens/NoteLockMigrationScreen.test.js
|
||||
packages/app-mobile/components/screens/NoteLockMigrationScreen.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.js
|
||||
packages/app-mobile/components/screens/NoteTagsDialog.js
|
||||
@@ -983,6 +934,7 @@ packages/app-mobile/components/screens/resourceScreenUtils.test.js
|
||||
packages/app-mobile/components/screens/resourceScreenUtils.js
|
||||
packages/app-mobile/components/screens/status.js
|
||||
packages/app-mobile/components/screens/tags.js
|
||||
packages/app-mobile/components/side-menu-content.js
|
||||
packages/app-mobile/components/testing/TestProviderStack.js
|
||||
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
|
||||
packages/app-mobile/components/voiceTyping/RecordingControls.js
|
||||
@@ -1064,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
|
||||
@@ -1160,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
|
||||
@@ -1190,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
|
||||
@@ -1203,7 +1154,6 @@ packages/editor/CodeMirror/pluginApi/PluginLoader.js
|
||||
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
|
||||
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js
|
||||
packages/editor/CodeMirror/pluginApi/customEditorCompletion.js
|
||||
packages/editor/CodeMirror/testing/backspaceOnce.js
|
||||
packages/editor/CodeMirror/testing/createEditorControl.js
|
||||
packages/editor/CodeMirror/testing/createTestEditor.js
|
||||
packages/editor/CodeMirror/testing/findNodesWithName.js
|
||||
@@ -1211,12 +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/clampSelectionToDocument.test.js
|
||||
packages/editor/CodeMirror/utils/clampSelectionToDocument.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
|
||||
@@ -1345,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
|
||||
@@ -1362,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
|
||||
@@ -1394,7 +1341,6 @@ packages/lib/SyncTargetWebDAV.js
|
||||
packages/lib/Synchronizer.js
|
||||
packages/lib/TaskQueue.test.js
|
||||
packages/lib/TaskQueue.js
|
||||
packages/lib/WebDavApi.test.js
|
||||
packages/lib/WebDavApi.js
|
||||
packages/lib/WelcomeUtils.test.js
|
||||
packages/lib/WelcomeUtils.js
|
||||
@@ -1434,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
|
||||
@@ -1449,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
|
||||
@@ -1468,7 +1412,6 @@ packages/lib/errorUtils.js
|
||||
packages/lib/errors.js
|
||||
packages/lib/eventManager.test.js
|
||||
packages/lib/eventManager.js
|
||||
packages/lib/file-api-driver-joplinServer.test.js
|
||||
packages/lib/file-api-driver-joplinServer.js
|
||||
packages/lib/file-api-driver-local.js
|
||||
packages/lib/file-api-driver-memory.js
|
||||
@@ -1525,11 +1468,7 @@ packages/lib/mimeUtils.test.js
|
||||
packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/ChatConversation.test.js
|
||||
packages/lib/models/ChatConversation.js
|
||||
packages/lib/models/ChatMessage.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
|
||||
@@ -1627,7 +1566,6 @@ packages/lib/services/ResourceEditWatcher/reducer.js
|
||||
packages/lib/services/ResourceFetcher.js
|
||||
packages/lib/services/ResourceService.test.js
|
||||
packages/lib/services/ResourceService.js
|
||||
packages/lib/services/RevisionService.noteLock.test.js
|
||||
packages/lib/services/RevisionService.test.js
|
||||
packages/lib/services/RevisionService.js
|
||||
packages/lib/services/SettingUtils.js
|
||||
@@ -1648,47 +1586,24 @@ 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
|
||||
packages/lib/services/ai/providers/ChatProviderBase.js
|
||||
packages/lib/services/ai/providers/JoplinCloud.test.js
|
||||
packages/lib/services/ai/providers/JoplinCloud.js
|
||||
packages/lib/services/ai/providers/OpenAiCompatible.test.js
|
||||
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/extractReasoning.test.js
|
||||
packages/lib/services/ai/utils/extractReasoning.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.test.js
|
||||
@@ -1699,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
|
||||
@@ -1727,7 +1629,6 @@ packages/lib/services/database/migrations/50.js
|
||||
packages/lib/services/database/migrations/51.js
|
||||
packages/lib/services/database/migrations/52.js
|
||||
packages/lib/services/database/migrations/53.js
|
||||
packages/lib/services/database/migrations/54.js
|
||||
packages/lib/services/database/migrations/index.js
|
||||
packages/lib/services/database/sqlStringToLines.js
|
||||
packages/lib/services/database/types.js
|
||||
@@ -1750,7 +1651,6 @@ packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
|
||||
packages/lib/services/e2ee/types.js
|
||||
packages/lib/services/e2ee/utils.test.js
|
||||
packages/lib/services/e2ee/utils.js
|
||||
packages/lib/services/interop/InteropService.noteLock.test.js
|
||||
packages/lib/services/interop/InteropService.test.js
|
||||
packages/lib/services/interop/InteropService.js
|
||||
packages/lib/services/interop/InteropService_Exporter_Base.js
|
||||
@@ -1766,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
|
||||
@@ -1796,33 +1693,35 @@ 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
|
||||
packages/lib/services/noteList/defaultListRenderer.js
|
||||
packages/lib/services/noteList/defaultMultiColumnsRenderer.js
|
||||
packages/lib/services/noteList/depNameToNoteProp.js
|
||||
packages/lib/services/noteList/isSyncDisabledConflict.test.js
|
||||
packages/lib/services/noteList/isSyncDisabledConflict.js
|
||||
packages/lib/services/noteList/renderTemplate.test.js
|
||||
packages/lib/services/noteList/renderTemplate.js
|
||||
packages/lib/services/noteList/renderViewProps.test.js
|
||||
packages/lib/services/noteList/renderViewProps.js
|
||||
packages/lib/services/noteList/renderers.js
|
||||
packages/lib/services/noteLock/NoteLockKey.js
|
||||
packages/lib/services/noteLock/NoteLockKeyMigration.test.js
|
||||
packages/lib/services/noteLock/NoteLockKeyMigration.js
|
||||
packages/lib/services/noteLock/NoteLockNote.js
|
||||
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/noteLockPrompts.test.js
|
||||
packages/lib/services/noteLock/noteLockPrompts.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
|
||||
@@ -1920,13 +1819,11 @@ packages/lib/services/rest/routes/events.js
|
||||
packages/lib/services/rest/routes/folders.test.js
|
||||
packages/lib/services/rest/routes/folders.js
|
||||
packages/lib/services/rest/routes/master_keys.js
|
||||
packages/lib/services/rest/routes/mcp.test.js
|
||||
packages/lib/services/rest/routes/mcp.js
|
||||
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
|
||||
@@ -1940,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
|
||||
@@ -1970,12 +1866,10 @@ 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
|
||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.noteLock.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.resources.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.revisions.test.js
|
||||
@@ -2023,8 +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/createLocalhostServer.js
|
||||
packages/lib/testing/dom-test-environment.js
|
||||
packages/lib/testing/plugins/createTestPlugin.js
|
||||
packages/lib/testing/share/makeMockShareInvitation.js
|
||||
@@ -2077,8 +1969,6 @@ packages/lib/utils/replaceUnsupportedCharacters.test.js
|
||||
packages/lib/utils/replaceUnsupportedCharacters.js
|
||||
packages/lib/utils/resolvePathWithinDir.test.js
|
||||
packages/lib/utils/resolvePathWithinDir.js
|
||||
packages/lib/utils/tls/setExtraRootCertificates.test.js
|
||||
packages/lib/utils/tls/setExtraRootCertificates.js
|
||||
packages/lib/utils/types/pdfJs.js
|
||||
packages/lib/utils/userFetcher.js
|
||||
packages/lib/utils/webDAVUtils.test.js
|
||||
@@ -2182,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
|
||||
@@ -2260,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
|
||||
@@ -209,7 +203,6 @@ packages/app-desktop/commands/openNoteInNewWindow.js
|
||||
packages/app-desktop/commands/openPrimaryAppInstance.js
|
||||
packages/app-desktop/commands/openProfileDirectory.js
|
||||
packages/app-desktop/commands/openSecondaryAppInstance.js
|
||||
packages/app-desktop/commands/openSyncWizard.js
|
||||
packages/app-desktop/commands/replaceMisspelling.js
|
||||
packages/app-desktop/commands/restoreNoteRevision.js
|
||||
packages/app-desktop/commands/showProfileEditor.js
|
||||
@@ -224,9 +217,7 @@ packages/app-desktop/commands/toggleSafeMode.js
|
||||
packages/app-desktop/commands/toggleTabMovesFocus.js
|
||||
packages/app-desktop/gui/AiDegradedNotice.js
|
||||
packages/app-desktop/gui/Button/Button.js
|
||||
packages/app-desktop/gui/ChatPanel/ChatHistory.js
|
||||
packages/app-desktop/gui/ChatPanel/ChatMessageItem.js
|
||||
packages/app-desktop/gui/ChatPanel/ChatPanel.test.js
|
||||
packages/app-desktop/gui/ChatPanel/ChatPanel.js
|
||||
packages/app-desktop/gui/ClipperConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
@@ -240,7 +231,6 @@ packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/NoteLockSettings.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingButton.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/SettingHeader.js
|
||||
@@ -252,7 +242,6 @@ packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/useNoteLockMode.js
|
||||
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
|
||||
packages/app-desktop/gui/ConfigScreen/types.js
|
||||
packages/app-desktop/gui/DialogButtonRow.js
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
||||
packages/app-desktop/gui/DialogTitle.js
|
||||
@@ -284,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
|
||||
@@ -338,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
|
||||
@@ -355,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
|
||||
@@ -404,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
|
||||
@@ -452,9 +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/NoteLockMigrationDialog/Dialog.test.js
|
||||
packages/app-desktop/gui/NoteLockMigrationDialog/Dialog.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,7 +521,6 @@ packages/app-desktop/gui/ToolbarSpace.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotification.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.js
|
||||
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
|
||||
packages/app-desktop/gui/WarningBanner.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
|
||||
@@ -550,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
|
||||
@@ -563,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
|
||||
@@ -571,7 +547,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openAiChatConversation.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
|
||||
@@ -584,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
|
||||
@@ -637,30 +611,21 @@ packages/app-desktop/gui/utils/NoteListUtils.js
|
||||
packages/app-desktop/gui/utils/announceForAccessibility.js
|
||||
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
|
||||
packages/app-desktop/gui/utils/dragAndDrop.js
|
||||
packages/app-desktop/gui/utils/hasNoteLockKey.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/JoplinCloudLoginScreen.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/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen/SyncTab.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
packages/app-desktop/integration-tests/multiWindow.spec.js
|
||||
packages/app-desktop/integration-tests/networking.spec.js
|
||||
packages/app-desktop/integration-tests/noteList.spec.js
|
||||
packages/app-desktop/integration-tests/pluginApi.spec.js
|
||||
packages/app-desktop/integration-tests/resizableLayout.spec.js
|
||||
@@ -736,11 +701,9 @@ packages/app-desktop/utils/customProtocols/registerCustomProtocols.js
|
||||
packages/app-desktop/utils/getAssetPath.js
|
||||
packages/app-desktop/utils/initReact.js
|
||||
packages/app-desktop/utils/initializeCommandService.js
|
||||
packages/app-desktop/utils/initializeNetworkConnectionAttemptTimeout.js
|
||||
packages/app-desktop/utils/isSafeToOpen.test.js
|
||||
packages/app-desktop/utils/isSafeToOpen.js
|
||||
packages/app-desktop/utils/layout/layoutKeyToLabel.js
|
||||
packages/app-desktop/utils/noteLockPrompts.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.js
|
||||
packages/app-desktop/utils/sourceMapSetup.js
|
||||
@@ -758,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
|
||||
@@ -856,11 +818,6 @@ packages/app-mobile/components/ScreenHeader/index.js
|
||||
packages/app-mobile/components/SearchInput.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
packages/app-mobile/components/SideMenu.js
|
||||
packages/app-mobile/components/SideMenuContent/BottomPanelActions.js
|
||||
packages/app-mobile/components/SideMenuContent/FolderItem.js
|
||||
packages/app-mobile/components/SideMenuContent/SideMenuContent.js
|
||||
packages/app-mobile/components/SideMenuContent/SideMenuItem.js
|
||||
packages/app-mobile/components/SideMenuContent/SidebarIcon.js
|
||||
packages/app-mobile/components/SideMenuContentNote.js
|
||||
packages/app-mobile/components/SyncWizard/JoplinCloudIcon.js
|
||||
packages/app-mobile/components/SyncWizard/SyncWizard.js
|
||||
@@ -893,7 +850,6 @@ packages/app-mobile/components/plugins/PluginNotification.js
|
||||
packages/app-mobile/components/plugins/PluginRunner.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.test.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/pluginRunnerBackgroundPage.js
|
||||
@@ -926,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
|
||||
@@ -971,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
|
||||
@@ -979,8 +932,6 @@ packages/app-mobile/components/screens/Note/commands/insertDateTime.js
|
||||
packages/app-mobile/components/screens/Note/commands/setTags.js
|
||||
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
|
||||
packages/app-mobile/components/screens/Note/types.js
|
||||
packages/app-mobile/components/screens/NoteLockMigrationScreen.test.js
|
||||
packages/app-mobile/components/screens/NoteLockMigrationScreen.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.test.js
|
||||
packages/app-mobile/components/screens/NoteRevisionViewer.js
|
||||
packages/app-mobile/components/screens/NoteTagsDialog.js
|
||||
@@ -1009,6 +960,7 @@ packages/app-mobile/components/screens/resourceScreenUtils.test.js
|
||||
packages/app-mobile/components/screens/resourceScreenUtils.js
|
||||
packages/app-mobile/components/screens/status.js
|
||||
packages/app-mobile/components/screens/tags.js
|
||||
packages/app-mobile/components/side-menu-content.js
|
||||
packages/app-mobile/components/testing/TestProviderStack.js
|
||||
packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
|
||||
packages/app-mobile/components/voiceTyping/RecordingControls.js
|
||||
@@ -1090,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
|
||||
@@ -1186,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
|
||||
@@ -1216,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
|
||||
@@ -1229,7 +1180,6 @@ packages/editor/CodeMirror/pluginApi/PluginLoader.js
|
||||
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
|
||||
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js
|
||||
packages/editor/CodeMirror/pluginApi/customEditorCompletion.js
|
||||
packages/editor/CodeMirror/testing/backspaceOnce.js
|
||||
packages/editor/CodeMirror/testing/createEditorControl.js
|
||||
packages/editor/CodeMirror/testing/createTestEditor.js
|
||||
packages/editor/CodeMirror/testing/findNodesWithName.js
|
||||
@@ -1237,12 +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/clampSelectionToDocument.test.js
|
||||
packages/editor/CodeMirror/utils/clampSelectionToDocument.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
|
||||
@@ -1371,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
|
||||
@@ -1388,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
|
||||
@@ -1420,7 +1367,6 @@ packages/lib/SyncTargetWebDAV.js
|
||||
packages/lib/Synchronizer.js
|
||||
packages/lib/TaskQueue.test.js
|
||||
packages/lib/TaskQueue.js
|
||||
packages/lib/WebDavApi.test.js
|
||||
packages/lib/WebDavApi.js
|
||||
packages/lib/WelcomeUtils.test.js
|
||||
packages/lib/WelcomeUtils.js
|
||||
@@ -1460,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
|
||||
@@ -1475,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
|
||||
@@ -1494,7 +1438,6 @@ packages/lib/errorUtils.js
|
||||
packages/lib/errors.js
|
||||
packages/lib/eventManager.test.js
|
||||
packages/lib/eventManager.js
|
||||
packages/lib/file-api-driver-joplinServer.test.js
|
||||
packages/lib/file-api-driver-joplinServer.js
|
||||
packages/lib/file-api-driver-local.js
|
||||
packages/lib/file-api-driver-memory.js
|
||||
@@ -1551,11 +1494,7 @@ packages/lib/mimeUtils.test.js
|
||||
packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/ChatConversation.test.js
|
||||
packages/lib/models/ChatConversation.js
|
||||
packages/lib/models/ChatMessage.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
|
||||
@@ -1653,7 +1592,6 @@ packages/lib/services/ResourceEditWatcher/reducer.js
|
||||
packages/lib/services/ResourceFetcher.js
|
||||
packages/lib/services/ResourceService.test.js
|
||||
packages/lib/services/ResourceService.js
|
||||
packages/lib/services/RevisionService.noteLock.test.js
|
||||
packages/lib/services/RevisionService.test.js
|
||||
packages/lib/services/RevisionService.js
|
||||
packages/lib/services/SettingUtils.js
|
||||
@@ -1674,47 +1612,24 @@ 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
|
||||
packages/lib/services/ai/providers/ChatProviderBase.js
|
||||
packages/lib/services/ai/providers/JoplinCloud.test.js
|
||||
packages/lib/services/ai/providers/JoplinCloud.js
|
||||
packages/lib/services/ai/providers/OpenAiCompatible.test.js
|
||||
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/extractReasoning.test.js
|
||||
packages/lib/services/ai/utils/extractReasoning.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.test.js
|
||||
@@ -1725,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
|
||||
@@ -1753,7 +1655,6 @@ packages/lib/services/database/migrations/50.js
|
||||
packages/lib/services/database/migrations/51.js
|
||||
packages/lib/services/database/migrations/52.js
|
||||
packages/lib/services/database/migrations/53.js
|
||||
packages/lib/services/database/migrations/54.js
|
||||
packages/lib/services/database/migrations/index.js
|
||||
packages/lib/services/database/sqlStringToLines.js
|
||||
packages/lib/services/database/types.js
|
||||
@@ -1776,7 +1677,6 @@ packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
|
||||
packages/lib/services/e2ee/types.js
|
||||
packages/lib/services/e2ee/utils.test.js
|
||||
packages/lib/services/e2ee/utils.js
|
||||
packages/lib/services/interop/InteropService.noteLock.test.js
|
||||
packages/lib/services/interop/InteropService.test.js
|
||||
packages/lib/services/interop/InteropService.js
|
||||
packages/lib/services/interop/InteropService_Exporter_Base.js
|
||||
@@ -1792,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
|
||||
@@ -1822,33 +1719,35 @@ 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
|
||||
packages/lib/services/noteList/defaultListRenderer.js
|
||||
packages/lib/services/noteList/defaultMultiColumnsRenderer.js
|
||||
packages/lib/services/noteList/depNameToNoteProp.js
|
||||
packages/lib/services/noteList/isSyncDisabledConflict.test.js
|
||||
packages/lib/services/noteList/isSyncDisabledConflict.js
|
||||
packages/lib/services/noteList/renderTemplate.test.js
|
||||
packages/lib/services/noteList/renderTemplate.js
|
||||
packages/lib/services/noteList/renderViewProps.test.js
|
||||
packages/lib/services/noteList/renderViewProps.js
|
||||
packages/lib/services/noteList/renderers.js
|
||||
packages/lib/services/noteLock/NoteLockKey.js
|
||||
packages/lib/services/noteLock/NoteLockKeyMigration.test.js
|
||||
packages/lib/services/noteLock/NoteLockKeyMigration.js
|
||||
packages/lib/services/noteLock/NoteLockNote.js
|
||||
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/noteLockPrompts.test.js
|
||||
packages/lib/services/noteLock/noteLockPrompts.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
|
||||
@@ -1946,13 +1845,11 @@ packages/lib/services/rest/routes/events.js
|
||||
packages/lib/services/rest/routes/folders.test.js
|
||||
packages/lib/services/rest/routes/folders.js
|
||||
packages/lib/services/rest/routes/master_keys.js
|
||||
packages/lib/services/rest/routes/mcp.test.js
|
||||
packages/lib/services/rest/routes/mcp.js
|
||||
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
|
||||
@@ -1966,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
|
||||
@@ -1996,12 +1892,10 @@ 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
|
||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.noteLock.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.resources.test.js
|
||||
packages/lib/services/synchronizer/Synchronizer.revisions.test.js
|
||||
@@ -2049,8 +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/createLocalhostServer.js
|
||||
packages/lib/testing/dom-test-environment.js
|
||||
packages/lib/testing/plugins/createTestPlugin.js
|
||||
packages/lib/testing/share/makeMockShareInvitation.js
|
||||
@@ -2103,8 +1995,6 @@ packages/lib/utils/replaceUnsupportedCharacters.test.js
|
||||
packages/lib/utils/replaceUnsupportedCharacters.js
|
||||
packages/lib/utils/resolvePathWithinDir.test.js
|
||||
packages/lib/utils/resolvePathWithinDir.js
|
||||
packages/lib/utils/tls/setExtraRootCertificates.test.js
|
||||
packages/lib/utils/tls/setExtraRootCertificates.js
|
||||
packages/lib/utils/types/pdfJs.js
|
||||
packages/lib/utils/userFetcher.js
|
||||
packages/lib/utils/webDAVUtils.test.js
|
||||
@@ -2208,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
|
||||
@@ -2286,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
|
||||
@@ -1,17 +0,0 @@
|
||||
diff --git a/types/react-native-web.d.ts b/types/react-native-web.d.ts
|
||||
index d6159045aa4b1f6864037cfc1c001a68605b416d..5950b82015f82f7840cf3bd2fc88e81dd0ff789c 100644
|
||||
--- a/types/react-native-web.d.ts
|
||||
+++ b/types/react-native-web.d.ts
|
||||
@@ -3,7 +3,11 @@
|
||||
import { StyleProp } from 'react-native';
|
||||
import * as RN from 'react-native';
|
||||
|
||||
-declare module 'react-native' {
|
||||
+// Expo's extended react-native types are out-of-sync with upstream.
|
||||
+// This causes TypeScript errors when compiling. These errors only occur
|
||||
+// when strict null checks is disabled, and so do not occur with the default React Native
|
||||
+// and Expo TypeScript config.
|
||||
+declare module 'react-native-disabled' {
|
||||
/**
|
||||
* View
|
||||
*/
|
||||
@@ -1,56 +0,0 @@
|
||||
# This patch makes react-native-quick-actions compatible with the iOS UIKit
|
||||
# scene-based lifecycle. Requires calling `setInitialAction` on app load when
|
||||
# started from a quick action.
|
||||
diff --git a/RNQuickAction/RNQuickAction/RNQuickActionManager.h b/RNQuickAction/RNQuickAction/RNQuickActionManager.h
|
||||
index ad4baad075513115892bd120a278f1ba664480b7..7a366391a48e77d7b08eb2741d2a499a267304b8 100644
|
||||
--- a/RNQuickAction/RNQuickAction/RNQuickActionManager.h
|
||||
+++ b/RNQuickAction/RNQuickAction/RNQuickActionManager.h
|
||||
@@ -10,4 +10,5 @@
|
||||
|
||||
@interface RNQuickActionManager : NSObject <RCTBridgeModule>
|
||||
+(void) onQuickActionPress:(UIApplicationShortcutItem *) shortcutItem completionHandler:(void (^)(BOOL succeeded)) completionHandler;
|
||||
++(void) setInitialAction:(UIApplicationShortcutItem *) shortcutItem;
|
||||
@end
|
||||
diff --git a/RNQuickAction/RNQuickAction/RNQuickActionManager.m b/RNQuickAction/RNQuickAction/RNQuickActionManager.m
|
||||
index 8db8cab947349763a9c3d9d6004868b675b9c7ef..3217a35634ad457e33756b895b32d80f3fa11178 100644
|
||||
--- a/RNQuickAction/RNQuickAction/RNQuickActionManager.m
|
||||
+++ b/RNQuickAction/RNQuickAction/RNQuickActionManager.m
|
||||
@@ -31,6 +31,7 @@ @implementation RNQuickActionManager
|
||||
RCT_EXPORT_MODULE();
|
||||
|
||||
@synthesize bridge = _bridge;
|
||||
+UIApplicationShortcutItem *lastInitialAction_ = NULL;
|
||||
|
||||
- (instancetype)init
|
||||
{
|
||||
@@ -39,6 +40,7 @@ - (instancetype)init
|
||||
selector:@selector(handleQuickActionPress:)
|
||||
name:RCTShortcutItemClicked
|
||||
object:nil];
|
||||
+ _initialAction = lastInitialAction_;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@@ -59,7 +61,9 @@ - (void)dealloc
|
||||
- (void)setBridge:(RCTBridge *)bridge
|
||||
{
|
||||
_bridge = bridge;
|
||||
- _initialAction = [bridge.launchOptions[UIApplicationLaunchOptionsShortcutItemKey] copy];
|
||||
+ // Patch: The initial action must now be set through `setInitialAction`:
|
||||
+ // _initialAction = NULL;
|
||||
+ // _initialAction = [bridge.launchOptions[UIApplicationLaunchOptionsShortcutItemKey] copy];
|
||||
}
|
||||
|
||||
// Map user passed array of UIApplicationShortcutItem
|
||||
@@ -155,6 +159,11 @@ + (void)onQuickActionPress:(UIApplicationShortcutItem *) shortcutItem completion
|
||||
completionHandler(YES);
|
||||
}
|
||||
|
||||
++ (void)setInitialAction:(UIApplicationShortcutItem *) shortcutItem
|
||||
+{
|
||||
+ lastInitialAction_ = [shortcutItem copy];
|
||||
+}
|
||||
+
|
||||
- (void)handleQuickActionPress:(NSNotification *) notification
|
||||
{
|
||||
[_bridge.eventDispatcher sendDeviceEventWithName:@"quickActionShortcut"
|
||||
@@ -1,47 +0,0 @@
|
||||
# Fixes the following error:
|
||||
# A problem occurred evaluating project ':react-native-rsa-native'.
|
||||
# > Could not find method jcenter() for arguments [] on repository container of type org.gradle.api.internal.artifacts.dsl.DefaultRepositoryHandler.
|
||||
#
|
||||
# See https://github.com/amitaymolko/react-native-rsa-native/issues/175
|
||||
diff --git a/android/bin/build.gradle b/android/bin/build.gradle
|
||||
index b5f54a631f4bed61973c77c2a6b27c5df0b26d17..28f38ca5966097f798f3f3607ded55dd4fe9b6d1 100644
|
||||
--- a/android/bin/build.gradle
|
||||
+++ b/android/bin/build.gradle
|
||||
@@ -1,7 +1,6 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
maven { url "https://maven.google.com" }
|
||||
- jcenter()
|
||||
google()
|
||||
maven {
|
||||
url 'https://maven.google.com/'
|
||||
@@ -41,7 +40,7 @@ allprojects {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
maven { url "https://maven.google.com" }
|
||||
- jcenter()
|
||||
+ mavenCentral()
|
||||
google()
|
||||
maven {
|
||||
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
||||
diff --git a/android/build.gradle b/android/build.gradle
|
||||
index 82f60d3d862258e50023076b0db6e792bd2a2503..9e4606903f2dcf4ee7bb49dc878a080023ed314c 100644
|
||||
--- a/android/build.gradle
|
||||
+++ b/android/build.gradle
|
||||
@@ -9,7 +9,6 @@ buildscript {
|
||||
if (project == rootProject) {
|
||||
repositories {
|
||||
google()
|
||||
- jcenter()
|
||||
maven {
|
||||
url "https://maven.fabric.io/public"
|
||||
}
|
||||
@@ -45,7 +44,7 @@ allprojects {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
google()
|
||||
- jcenter()
|
||||
+ mavenCentral()
|
||||
maven {
|
||||
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
||||
url "$rootDir/../node_modules/react-native/android"
|
||||
@@ -0,0 +1,37 @@
|
||||
diff --git a/platforms/android/src/main/java/org/pgsqlite/SQLitePlugin.java b/platforms/android/src/main/java/org/pgsqlite/SQLitePlugin.java
|
||||
index 4f2391b..f7cc433 100644
|
||||
--- a/platforms/android/src/main/java/org/pgsqlite/SQLitePlugin.java
|
||||
+++ b/platforms/android/src/main/java/org/pgsqlite/SQLitePlugin.java
|
||||
@@ -8,11 +8,14 @@
|
||||
package org.pgsqlite;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
+import android.database.AbstractWindowedCursor;
|
||||
import android.database.Cursor;
|
||||
+import android.database.CursorWindow;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteException;
|
||||
import android.database.sqlite.SQLiteStatement;
|
||||
import android.content.Context;
|
||||
+import android.os.Build;
|
||||
import android.util.Base64;
|
||||
|
||||
import java.io.Closeable;
|
||||
@@ -808,6 +811,17 @@ public class SQLitePlugin extends ReactContextBaseJavaModule {
|
||||
throw ex;
|
||||
}
|
||||
|
||||
+ // To try to fix the error "Row too big to fit into CursorWindow" when using sqlite binary bundled with the device
|
||||
+ // https://github.com/andpor/react-native-sqlite-storage/issues/364#issuecomment-526423153
|
||||
+ // https://github.com/laurent22/joplin/issues/1767#issuecomment-515617991
|
||||
+
|
||||
+ if (cur != null && Build.VERSION.SDK_INT >= 28) {
|
||||
+ CursorWindow cw = new CursorWindow(null, 50 * 1024 * 1024);
|
||||
+ AbstractWindowedCursor ac = (AbstractWindowedCursor) cur;
|
||||
+ ac.setWindow(cw);
|
||||
+ cur = ac;
|
||||
+ }
|
||||
+
|
||||
// If query result has rows
|
||||
if (cur != null && cur.moveToFirst()) {
|
||||
WritableArray rowsArrayResult = Arguments.createArray();
|
||||
@@ -1,25 +1,6 @@
|
||||
# Fixes the following errors:
|
||||
# - Could not find method jcenter() for arguments [] on repository container of type org.gradle.api.internal.artifacts.dsl.DefaultRepositoryHandler.
|
||||
# - Sync issues caused by locale-sensitive lowercasing of HTTP headers (see https://github.com/laurent22/joplin/issues/10681).
|
||||
diff --git a/android/build.gradle b/android/build.gradle
|
||||
index a4ca7a421bff66bcc378c1092474bac6b7a82738..d2527c7e695d09dac3069fecd14931eae33a03e7 100644
|
||||
--- a/android/build.gradle
|
||||
+++ b/android/build.gradle
|
||||
@@ -6,13 +6,12 @@ def safeExtGet(prop, fallback) {
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
- jcenter()
|
||||
google()
|
||||
}
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
- jcenter()
|
||||
+ mavenCentral()
|
||||
google()
|
||||
}
|
||||
dependencies {
|
||||
# Fixes sync issues caused by locale-sensitive lowercasing
|
||||
# of HTTP headers.
|
||||
# See https://github.com/laurent22/joplin/issues/10681
|
||||
diff --git a/android/src/main/java/com/RNFetchBlob/RNFetchBlobConfig.java b/android/src/main/java/com/RNFetchBlob/RNFetchBlobConfig.java
|
||||
index 8ac9e7a855162cefbf99024eb013c8a3b11de1ec..1c639cf9d84821b6ffc132960e2d1c044bedbd48 100644
|
||||
--- a/android/src/main/java/com/RNFetchBlob/RNFetchBlobConfig.java
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
diff --git a/binary.js b/binary.js
|
||||
index f91bdbd2e4b9bfb3a6376849598a9352f8678584..f42f96aef91c483c88fdd47ff82473069ce3456d 100644
|
||||
--- a/binary.js
|
||||
+++ b/binary.js
|
||||
@@ -9,7 +9,9 @@ const getPlatform = () => {
|
||||
const arch = os.arch();
|
||||
|
||||
// https://github.com/nodejs/node/blob/c3664227a83cf009e9a2e1ddeadbd09c14ae466f/deps/uv/src/win/util.c#L1566-L1573
|
||||
- if ((type === "Windows_NT" || type.startsWith("MINGW32_NT-")) && arch === "x64") {
|
||||
+ // arm64 uses the x64 binary, which runs under emulation - as for Darwin
|
||||
+ // below. Upstream publishes no aarch64-pc-windows-msvc build.
|
||||
+ if ((type === "Windows_NT" || type.startsWith("MINGW32_NT-")) && (arch === "x64" || arch === "arm64")) {
|
||||
return windows;
|
||||
}
|
||||
if (type === "Linux" && arch === "x64") {
|
||||
@@ -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,6 +1,3 @@
|
||||
// Set to true once a Windows ARM64 build is published in a release.
|
||||
const windowsArm64Enabled = false;
|
||||
|
||||
async function getOs() {
|
||||
|
||||
// The macOS release is available for Intel and Apple silicon processors,
|
||||
@@ -28,21 +25,7 @@ async function getOs() {
|
||||
}
|
||||
}
|
||||
|
||||
// Windows on ARM has the same appVersion as x64, so the architecture has to
|
||||
// come from getHighEntropyValues, as for Apple Silicon above. Falling back
|
||||
// to the x64 build is fine - it runs on ARM under emulation.
|
||||
if (navigator.appVersion.indexOf("Win")!=-1) {
|
||||
if (windowsArm64Enabled) {
|
||||
try {
|
||||
const platformInfo = await navigator.userAgentData.getHighEntropyValues(['architecture']);
|
||||
console.info('Got platform info:', platformInfo);
|
||||
if (platformInfo.architecture === 'arm') return "windowsArm64";
|
||||
} catch (error) {
|
||||
console.warn('Failed getting Windows architecture:', error);
|
||||
}
|
||||
}
|
||||
return "windows";
|
||||
}
|
||||
if (navigator.appVersion.indexOf("Win")!=-1) return "windows";
|
||||
if (navigator.appVersion.indexOf("X11")!=-1) return "linux";
|
||||
if (navigator.appVersion.indexOf("Linux")!=-1) return "linux";
|
||||
return null;
|
||||
@@ -95,12 +78,7 @@ async function setupDownloadPage() {
|
||||
$('.page-download .get-it-desktop a').each(function() {
|
||||
const href = $(this).attr('href');
|
||||
|
||||
// The arm64 installer is also named "-Setup", so it must be excluded
|
||||
// from the x64 link even when disabled, or it would overwrite it.
|
||||
if (href.indexOf('-arm64.exe') > 0) {
|
||||
if (windowsArm64Enabled) downloadLinks['windowsArm64'] = href;
|
||||
}
|
||||
else if (href.indexOf('-Setup') > 0) downloadLinks['windows'] = href;
|
||||
if (href.indexOf('-Setup') > 0) downloadLinks['windows'] = href;
|
||||
if (href.indexOf('.dmg') > 0) downloadLinks['macOs'] = href;
|
||||
if (href.indexOf('arm64.DMG') > 0) downloadLinks['macOsM1'] = href;
|
||||
if (href.indexOf('.AppImage') > 0) downloadLinks['linux'] = href;
|
||||
@@ -153,7 +131,7 @@ const localePathOverrides = {
|
||||
};
|
||||
|
||||
// List of supported language codes
|
||||
const supportedLanguages = ['fr', 'de', 'hr', 'zh'];
|
||||
const supportedLanguages = ['fr', 'de', 'zh'];
|
||||
|
||||
function getLocalePath(langCode) {
|
||||
const pathPrefix = localePathOverrides[langCode] || langCode;
|
||||
|
||||
@@ -1,83 +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>Wed, 21 Oct 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate><item><title><![CDATA[How we protect your privacy with Joplin Cloud AI]]></title><description><![CDATA[<p>Joplin Cloud AI features work on your notes. Notes are often personal, so we've set up the AI service so that your content goes to as few places as possible and stays there for as short a time as possible.</p>
|
||||
<p><strong>A European AI provider, processing in Europe.</strong> Joplin Cloud AI uses models from Mistral AI, a French company. We send requests to Mistral's EU regional endpoint, which processes data only in data centres in the European Union and EFTA countries. Your notes are not sent to the United States for processing.</p>
|
||||
<p><strong>Zero Data Retention.</strong> Mistral has enabled Zero Data Retention on our account. Once a response has been generated, Mistral doesn't store or log the content of the request or the response. Only basic billing information, such as how many tokens were used, is kept.</p>
|
||||
<p><strong>No training on your notes.</strong> Your content isn't used to train or improve AI models. Mistral's commercial terms rule this out for our account, and training is switched off in our account settings.</p>
|
||||
<p><strong>GDPR.</strong> Our use of Mistral is covered by their Data Processing Addendum, which sets out how they may handle personal data on our behalf under the GDPR.</p>
|
||||
<p><strong>What Joplin Cloud stores.</strong> Our own servers don't keep the text you send to the AI or the replies you get back. For each request we only record who made it, which model was used, how many tokens it used and whether it succeeded. We need this to apply usage limits and keep track of costs.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20261021-joplin-cloud-ai-privacy</link><guid isPermaLink="false">20261021-joplin-cloud-ai-privacy</guid><pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Your notes and AI: privacy first]]></title><description><![CDATA[<p>AI can be useful for summarising, rewriting and asking questions about your notes. But your notes are private, so you should always know where your data is going.</p>
|
||||
<p><strong>Joplin does not send your notes to an AI service by default.</strong> AI features are disabled by default, and you have to explicitly enable them and choose an AI provider. <a href="https://joplinapp.org/help/apps/ai_chat">Learn more about AI Chat</a>.</p>
|
||||
<h2>Keep your notes on your own device<a name="keep-your-notes-on-your-own-device" href="#keep-your-notes-on-your-own-device" class="heading-anchor">🔗</a></h2>
|
||||
<p>You can connect Joplin to a local AI model, such as Ollama or LM Studio. In this case, your notes stay within your own device or private network and are not sent to an external service.</p>
|
||||
<p>Joplin also blocks remote AI providers by default, as an additional safeguard.</p>
|
||||
<h2>You control what AI can access<a name="you-control-what-ai-can-access" href="#you-control-what-ai-can-access" class="heading-anchor">🔗</a></h2>
|
||||
<p>Joplin lets you choose <strong>which tools are available to AI</strong>. This applies both to AI Chat and to the Model Context Protocol (MCP) integration.</p>
|
||||
<p>For example, you can allow an AI assistant to search your notes without giving it permission to modify them. You can enable or disable individual tools depending on what you are comfortable with.</p>
|
||||
<p>This means that connecting an AI service to Joplin does not automatically give it unrestricted access to your data or to all of Joplin's capabilities. <a href="https://joplinapp.org/help/apps/ai_mcp">Learn more about MCP</a>.</p>
|
||||
<h2>If you use a cloud AI provider<a name="if-you-use-a-cloud-ai-provider" href="#if-you-use-a-cloud-ai-provider" class="heading-anchor">🔗</a></h2>
|
||||
<p>You can connect Joplin to cloud AI services such as Joplin Cloud AI, OpenAI or Anthropic. When you do, the relevant data is sent to the provider you have selected.</p>
|
||||
<p>For AI Chat, Joplin only sends the note content required for your request — such as the currently open note or selected text — rather than automatically sending your entire notebook. <a href="https://joplinapp.org/help/apps/ai_chat_panel">Learn more about the AI Chat panel</a>.</p>
|
||||
<h2>You are in control<a name="you-are-in-control" href="#you-are-in-control" class="heading-anchor">🔗</a></h2>
|
||||
<p>Joplin's approach is simple: <strong>AI should be opt-in, transparent and configurable.</strong></p>
|
||||
<p>You choose whether to use AI, which provider to use, whether to use a local or remote model, and which tools that AI is allowed to access.</p>
|
||||
<p>If you want maximum privacy, use a local AI model and only enable the tools you actually need.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20260914-ai-privacy</link><guid isPermaLink="false">20260914-ai-privacy</guid><pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><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>
|
||||
@@ -564,4 +485,102 @@ sys 0m38.013s</p>
|
||||
<li><a href="https://ryotak.net/">RyotaK</a></li>
|
||||
<li><a href="https://twitter.com/YNizry">Yaniv Nizry</a></li>
|
||||
</ul>
|
||||
]]></description><link>https://joplinapp.org/news/20231023-white-hat-hackers</link><guid isPermaLink="false">20231023-white-hat-hackers</guid><pubDate>Mon, 23 Oct 2023 00:00:00 GMT</pubDate><twitter-text>Working in the shadows with white-hat hackers</twitter-text></item></channel></rss>
|
||||
]]></description><link>https://joplinapp.org/news/20231023-white-hat-hackers</link><guid isPermaLink="false">20231023-white-hat-hackers</guid><pubDate>Mon, 23 Oct 2023 00:00:00 GMT</pubDate><twitter-text>Working in the shadows with white-hat hackers</twitter-text></item><item><title><![CDATA[What's new in Joplin 2.12]]></title><description><![CDATA[<h2>Desktop<a name="desktop" href="#desktop" class="heading-anchor">🔗</a></h2>
|
||||
<h3>Support for Apple Silicon<a name="support-for-apple-silicon" href="#support-for-apple-silicon" class="heading-anchor">🔗</a></h3>
|
||||
<p>A new release is now available for Apple Silicon, which provides improve performances on this architecture.</p>
|
||||
<h3>Rich Text editor<a name="rich-text-editor" href="#rich-text-editor" class="heading-anchor">🔗</a></h3>
|
||||
<p>In this release, we've undertaken numerous enhancements and addressed various bugs in the Rich Text editor. Notably, we've introduced support for plugin toolbar icons. Additionally, we've refined the editor's ability to manage text that's copied from applications like Word, Office, and LibreOffice, thereby enhancing cross-application compatibility. Among the minor yet impactful improvements, we've fine-tuned the handling of newlines and paragraphs—a highly requested feature. Another notable update is the improved automatic switching between light and dark modes.</p>
|
||||
<p>Altogether, this release encompasses around 12 significant changes for the Rich Text editor, and rest assured, there's more to come in the future!</p>
|
||||
<h3>Share permissions<a name="share-permissions" href="#share-permissions" class="heading-anchor">🔗</a></h3>
|
||||
<p>Using Joplin Cloud Teams, you now have the ability to customize the read and write permissions for the notebooks you share. You have the option to grant other users permission to edit the notes or share them as read-only. This ensures that you can confidently share a notebook without worrying about unintentional modifications by your friends or colleagues.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230825-share-permissions.png" alt=""></p>
|
||||
<h3>Email to Note<a name="email-to-note" href="#email-to-note" class="heading-anchor">🔗</a></h3>
|
||||
<p>Joplin Cloud Pro and Teams also now include the Email to Note feature, allowing you to conveniently store your emails within Joplin Cloud. By simply forwarding your emails to your Joplin Cloud address, you can transform them into notes. The email's subject will serve as the note title, while the body of the email will be the note's content. These notes will be organized within a notebook named "Inbox."</p>
|
||||
<p>More information in the <a href="https://joplinapp.org/help/apps/email_to_note">Email to Note documentation</a>.</p>
|
||||
<h3>Choose to resize an image or not<a name="choose-to-resize-an-image-or-not" href="#choose-to-resize-an-image-or-not" class="heading-anchor">🔗</a></h3>
|
||||
<p>By default, when you add a large image, Joplin will ask you if you would like to shrink it down or not. With this new release, you now have the option to always ask, to always resize, or to never resize the image, giving you more flexibility and reducing the number of prompts in the app.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230825-resize-note.png" alt=""></p>
|
||||
<h3>Rotating log<a name="rotating-log" href="#rotating-log" class="heading-anchor">🔗</a></h3>
|
||||
<p>Up until now, the logs could grow to become very large, and if you wanted to shrink them down you would have to manually delete them. With this new release, we now automatically handle logs by rotating them - once the log becomes large enough it is moved to an archive. And once the archive is older than a number of days, it is deleted.</p>
|
||||
<h3>Mobile<a name="mobile" href="#mobile" class="heading-anchor">🔗</a></h3>
|
||||
<p>Like the desktop app, the mobile apps benefits from the Share Permissions update, as well as the Email to Note feature for Joplin Cloud. There are also plenty of other improvements - all in all there's 23 bug fixes, enhancements and security fixes in this release.</p>
|
||||
<h2>Full changelog<a name="full-changelog" href="#full-changelog" class="heading-anchor">🔗</a></h2>
|
||||
<p>This is just an overview of the main features. The full changelog is available there:</p>
|
||||
<ul>
|
||||
<li>Desktop: <a href="https://joplinapp.org/help/about/changelog/desktop">https://joplinapp.org/help/about/changelog/desktop</a></li>
|
||||
<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-12</link><guid isPermaLink="false">20230508-release-2-12</guid><pubDate>Wed, 06 Sep 2023 12:00:00 GMT</pubDate><twitter-text>What's new in Joplin 2.12</twitter-text></item><item><title><![CDATA[What's new in Joplin 2.11]]></title><description><![CDATA[<h2>Desktop<a name="desktop" href="#desktop" class="heading-anchor">🔗</a></h2>
|
||||
<h3>Add support for plugin user data<a name="add-support-for-plugin-user-data" href="#add-support-for-plugin-user-data" class="heading-anchor">🔗</a></h3>
|
||||
<p>Developers of plugins now have the ability to associate additional information with notes, notebooks, and tags. This data is then synchronized across multiple devices. For instance, it is possible to envision attaching OCR data to an image or incorporating plugin-specific parameters to a note, which can subsequently be synchronized across all devices.</p>
|
||||
<h3>Improved end-to-end encryption<a name="improved-end-to-end-encryption" href="#improved-end-to-end-encryption" class="heading-anchor">🔗</a></h3>
|
||||
<p>End-to-end encryption (E2EE) plays a vital role in safeguarding your data privacy. Our aim is to make it effortlessly user-friendly, seamlessly integrated, and possibly enabled by default.</p>
|
||||
<p>To achieve this objective, we have implemented several enhancements in this version. These improvements encompass more streamlined management of encryption keys and simplified setup processes for both new and existing devices utilising end-to-end encryption.</p>
|
||||
<p>Moreover, we have upgraded the encryption algorithm from AES-128 to AES-256, ensuring heightened security for your data in the long run.</p>
|
||||
<h3>Export a Mermaid graph<a name="export-a-mermaid-graph" href="#export-a-mermaid-graph" class="heading-anchor">🔗</a></h3>
|
||||
<p>There is now a context menu on Mermaid graphs which you can use to export your graph as PNG, SVG, or by copying the vector data to the clipboard.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230711-mermaid-export.png" alt=""></p>
|
||||
<h3>Auto-detect language on startup<a name="auto-detect-language-on-startup" href="#auto-detect-language-on-startup" class="heading-anchor">🔗</a></h3>
|
||||
<p>A small but useful change - the application language is now automatically detected startup. Of course, if needed you can later change it in the configuration screen.</p>
|
||||
<h2>Android<a name="android" href="#android" class="heading-anchor">🔗</a></h2>
|
||||
<p>As always most of these changes also apply to the mobile applications, such as the improved end-to-end encryption support, language auto-detection, and various bug fixes and improvements. But besides those, we also have several mobile-specific updates:</p>
|
||||
<h3>Support for offline voice typing<a name="support-for-offline-voice-typing" href="#support-for-offline-voice-typing" class="heading-anchor">🔗</a></h3>
|
||||
<p>The Android mobile app now support offline voice typing - it means your voice recording does not need to be uploaded to Google or Samsung server - it all takes place on your device for added security. To make use of it, open any note, tap on the three dots menu, then "Voice typing".</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230711-voice-typing-1.png" alt=""></p>
|
||||
<p>The first time, the app will need to download the language files to your device:</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230711-voice-typing-2.png" alt=""></p>
|
||||
<p>Once they are there, simply record your message and it will appear in the note:</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230711-voice-typing-3.png" alt=""></p>
|
||||
<h3>Create and move sub-notebooks<a name="create-and-move-sub-notebooks" href="#create-and-move-sub-notebooks" class="heading-anchor">🔗</a></h3>
|
||||
<p>@jcgurango implemented support for creating sub-notebooks, and moving notebooks around in the mobile app. To do so, long press on any notebook and press "Edit". You can now choose to move it under any other notebook using the provided dropdown list.</p>
|
||||
<h2>Full changelog<a name="full-changelog" href="#full-changelog" class="heading-anchor">🔗</a></h2>
|
||||
<p>This is just an overview of the main features. The full changelog is available there:</p>
|
||||
<ul>
|
||||
<li>Desktop: <a href="https://joplinapp.org/help/about/changelog/desktop">https://joplinapp.org/help/about/changelog/desktop</a></li>
|
||||
<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><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>
|
||||
@@ -300,7 +300,7 @@
|
||||
A <span class="frame-bg frame-bg-yellow-lg">French</span> Alternative
|
||||
</h2>
|
||||
<p translate>
|
||||
Joplin Cloud is based in France. This means your data is protected by strict European Union privacy laws. In addition, Joplin Cloud implements strong end-to-end encryption so that not even we can access your data.
|
||||
Joplin Cloud is based in France. This means your data is protected by strict European Union privacy laws. In addition, Joplin Cloud implements strong end-to-end encryption so that not even us can have access to your data.
|
||||
</p>
|
||||
<br />
|
||||
<br />
|
||||
|
||||
@@ -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>
|
||||
@@ -2,7 +2,7 @@
|
||||
# Build stage
|
||||
# =============================================================================
|
||||
|
||||
FROM node:24.22.1 AS builder
|
||||
FROM node:24 AS builder
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
@@ -66,7 +66,7 @@ RUN --mount=type=cache,target=/build/.yarn/cache --mount=type=cache,target=/buil
|
||||
# from a smaller base image.
|
||||
# =============================================================================
|
||||
|
||||
FROM node:24.22.1-slim
|
||||
FROM node:24-slim
|
||||
|
||||
ARG user=joplin
|
||||
RUN useradd --create-home --shell /bin/bash $user
|
||||
@@ -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" \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.22.1-bookworm
|
||||
FROM node:24-bookworm
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
FROM ghcr.io/ggml-org/llama.cpp:full-cuda-b5449 AS llama-cuda
|
||||
|
||||
# Stage 2: our runtime image.
|
||||
FROM nvidia/cuda:13.3.0-cudnn-runtime-ubuntu22.04
|
||||
FROM nvidia/cuda:13.2.1-cudnn-runtime-ubuntu22.04
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
|
||||
@@ -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")
|
||||
@@ -262,6 +259,42 @@ DESKTOP=${DESKTOP,,} # convert to lower case
|
||||
|
||||
echo 'Create Desktop icon...'
|
||||
|
||||
# Detect distribution environment, and apply --no-sandbox fix
|
||||
SANDBOXPARAM=""
|
||||
# lsb_release isn't available on some platforms (e.g. opensuse)
|
||||
# The equivalent of lsb_release in OpenSuse is the file /usr/lib/os-release
|
||||
if command -v lsb_release &> /dev/null; then
|
||||
DISTVER=$(lsb_release -is) && DISTVER=$DISTVER$(lsb_release -rs)
|
||||
DISTCODENAME=$(lsb_release -cs)
|
||||
DISTMAJOR=$(lsb_release -rs|cut -d. -f1)
|
||||
|
||||
#-----------------------------------------------------
|
||||
# Check for "The SUID sandbox helper binary was found, but is not configured correctly" problem.
|
||||
# It is present in Debian 1X. A (temporary) patch will be applied at .desktop file
|
||||
# Linux Mint 4 Debbie is based on Debian 10 and requires the same param handling.
|
||||
#
|
||||
# TODO: Remove: This is likely no longer an issue. See https://issues.chromium.org/issues/40462640.
|
||||
BAD_HELPER_BINARY=false
|
||||
if [[ $DISTVER =~ Debian1. || ( "$DISTVER" = "Linuxmint4" && "$DISTCODENAME" = "debbie" ) || ( "$DISTVER" = "CentOS" && "$DISTMAJOR" =~ 6|7 ) ]]; then
|
||||
BAD_HELPER_BINARY=true
|
||||
fi
|
||||
|
||||
# Work around Ubuntu 23.10+'s restrictions on unprivileged user namespaces. Electron
|
||||
# uses these to sandbox processes. Unfortunately, it doesn't look like we can get around this
|
||||
# without writing the AppImage to a non-user-writable location (without invalidating other security
|
||||
# controls). See https://discourse.joplinapp.org/t/possible-future-requirement-for-no-sandbox-flag-for-ubuntu-23-10/.
|
||||
HAS_USERNS_RESTRICTIONS=false
|
||||
if [[ "$DISTVER" =~ ^(Ubuntu|Tuxedo) && $DISTMAJOR -ge 23 ]]; then
|
||||
HAS_USERNS_RESTRICTIONS=true
|
||||
fi
|
||||
|
||||
if [[ $HAS_USERNS_RESTRICTIONS = true || $BAD_HELPER_BINARY = true ]]; then
|
||||
SANDBOXPARAM="--no-sandbox"
|
||||
print "${COLOR_YELLOW}WARNING${COLOR_RESET} Electron sandboxing disabled."
|
||||
print " See https://discourse.joplinapp.org/t/32160/5 for details."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Initially only desktop environments that were confirmed to use desktop files stored in
|
||||
# `.local/share/desktop` had a desktop file created.
|
||||
# However some environments don't return a desktop BUT still support these desktop files
|
||||
@@ -274,7 +307,13 @@ if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cin
|
||||
DATA_HOME=${XDG_DATA_HOME:-~/.local/share}
|
||||
DESKTOP_FILE_LOCATION="$DATA_HOME/applications"
|
||||
|
||||
STARTUP_WM_CLASS=appimagekit-joplin
|
||||
# Only later versions of Joplin default to Wayland
|
||||
IS_WAYLAND_BY_DEFAULT=$(compareVersions "$RELEASE_VERSION" "3.5.6")
|
||||
# Joplin has a different startup WM class on Wayland and X11:
|
||||
STARTUP_WM_CLASS=Joplin
|
||||
if [[ $XDG_SESSION_TYPE != "x11" && $IS_WAYLAND_BY_DEFAULT == "1" ]]; then
|
||||
STARTUP_WM_CLASS=@joplin/app-desktop
|
||||
fi
|
||||
|
||||
# Only delete the desktop file if it will be replaced
|
||||
rm -f "$DESKTOP_FILE_LOCATION/appimagekit-joplin.desktop"
|
||||
@@ -288,8 +327,10 @@ if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cin
|
||||
Encoding=UTF-8
|
||||
Name=Joplin
|
||||
Comment=Joplin for Desktop
|
||||
Exec=env APPIMAGELAUNCHER_DISABLE=TRUE "${INSTALL_DIR}/Joplin.AppImage" %u
|
||||
Exec=env APPIMAGELAUNCHER_DISABLE=TRUE "${INSTALL_DIR}/Joplin.AppImage" ${SANDBOXPARAM} %u
|
||||
Icon=joplin
|
||||
# This will be different between Wayland and X11. On Wayland, the startup
|
||||
# WM class is "@joplin/app-desktop". On X11, it's "Joplin".
|
||||
StartupWMClass=${STARTUP_WM_CLASS}
|
||||
Type=Application
|
||||
Categories=Office;
|
||||
|
||||
@@ -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://essayservice.com/nursing-essay-writing-service"><img title="write my nursing paper today" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="write my nursing paper today"/></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 -->
|
||||
|
||||
* * *
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Before submitting a report, please read the [Rules for reporting](#rules-for-reporting).
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest version is supported with security updates.
|
||||
@@ -13,14 +10,6 @@ Please report vulnerabilities [through private vulnerability reporting](https://
|
||||
|
||||
For general opinions on what makes an app more or less secure, please use the forum.
|
||||
|
||||
## Rules for reporting
|
||||
|
||||
Joplin is maintained by a small team, and we receive a high volume of reports that have not been verified by the person submitting them. Reviewing and reproducing an unverified finding takes us as long as a real one, so we need a few rules to keep the process workable for everyone.
|
||||
|
||||
- **Two active reports per person.** If you have more than two open at once, we may close them all, and we may block the account.
|
||||
|
||||
- **Verify your report before submitting it.** Confirm that the issue is still present in the latest version, and that you have actually carried out the steps your conclusion relies on. Repeatedly submitting findings that do not hold up may result in a permanent block.
|
||||
|
||||
## Areas outside Joplin's Threat Model
|
||||
|
||||
Note: we're mostly linking to Chrome's documentation since our reasoning for these exclusions is the same.
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
},
|
||||
"nodejs": "24.12.0",
|
||||
"pkg-config": "latest",
|
||||
"python": "3.14.4",
|
||||
"python": "3.14.2",
|
||||
"bat": "latest",
|
||||
"electron": {
|
||||
"version": "latest",
|
||||
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"git": "2.53.0",
|
||||
"git": "2.52.0",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -33,33 +33,3 @@ if (typeof document !== 'undefined') {
|
||||
return range;
|
||||
};
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
const { Buffer, Blob, File } = require('node:buffer');
|
||||
const { TextDecoder, TextEncoder } = require('node:util');
|
||||
const { URLSearchParams, URL } = require('node:url');
|
||||
const { ReadableStream } = require('node:stream/web');
|
||||
const { MessageChannel, MessagePort } = require('node:worker_threads');
|
||||
const { setTimeout, setInterval, clearTimeout, clearInterval, setImmediate, clearImmediate } = require('node:timers');
|
||||
const { performance } = require('node:perf_hooks');
|
||||
|
||||
window.performance.markResourceTiming = performance.markResourceTiming;
|
||||
|
||||
// Override some of the JSDom globals. Some libraries (e.g. Undici) require the
|
||||
// original Node.js versions of these globals:
|
||||
globalThis.Buffer = Buffer;
|
||||
globalThis.Blob = Blob;
|
||||
globalThis.File = File;
|
||||
globalThis.URLSearchParams = URLSearchParams;
|
||||
globalThis.URL = URL;
|
||||
globalThis.TextDecoder = TextDecoder;
|
||||
globalThis.TextEncoder = TextEncoder;
|
||||
globalThis.ReadableStream = ReadableStream;
|
||||
globalThis.MessageChannel = MessageChannel;
|
||||
globalThis.MessagePort = MessagePort;
|
||||
globalThis.setTimeout = setTimeout;
|
||||
globalThis.setInterval = setInterval;
|
||||
globalThis.clearTimeout = clearTimeout;
|
||||
globalThis.clearInterval = clearInterval;
|
||||
globalThis.setImmediate = setImmediate;
|
||||
globalThis.clearImmediate = clearImmediate;
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
"url": "git+https://github.com/laurent22/joplin.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19",
|
||||
"yarn": "4.16.0"
|
||||
"node": ">=22.12",
|
||||
"yarn": "4.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"buildApiDoc": "yarn workspace joplin start apidoc ../../readme/api/references/rest_api.md",
|
||||
@@ -77,50 +77,53 @@
|
||||
"@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",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jest": "29.15.4",
|
||||
"eslint-plugin-jest": "29.15.2",
|
||||
"eslint-plugin-promise": "7.3.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "11.3.6",
|
||||
"fs-extra": "11.3.4",
|
||||
"glob": "11.1.0",
|
||||
"globals": "17.7.0",
|
||||
"globals": "17.6.0",
|
||||
"gulp": "4.0.2",
|
||||
"husky": "9.1.7",
|
||||
"lerna": "3.22.1",
|
||||
"lint-staged": "16.4.0",
|
||||
"madge": "8.0.0",
|
||||
"npm-package-json-lint": "10.4.1",
|
||||
"npm-package-json-lint": "9.1.0",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"eslint-plugin-github": "4.10.2",
|
||||
"http-server": "14.1.1",
|
||||
"node-gyp": "12.4.0",
|
||||
"node-gyp": "11.5.0",
|
||||
"nodemon": "3.1.14"
|
||||
},
|
||||
"packageManager": "yarn@4.16.0",
|
||||
"resolutions": {
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@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.27.0",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"eslint": "patch:eslint@9.39.4#./.yarn/patches/eslint-npm-9.39.4-81a84865c0.patch",
|
||||
"yauzl": "3.4.0",
|
||||
"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.6.0",
|
||||
"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",
|
||||
"react-native@0.79.2": "patch:react-native@npm%3A0.79.2#./.yarn/patches/react-native-npm-0.79.2-9db13eddfe.patch",
|
||||
@@ -129,14 +132,10 @@
|
||||
"pdfjs-dist@3.11.174": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
|
||||
"canvas@npm:^2.11.2": "link:./.yarn/joplin-empty-package/",
|
||||
"@huggingface/transformers/sharp": "link:./.yarn/joplin-empty-package/",
|
||||
"node-gyp@npm:^9.0.0": "12.4.0",
|
||||
"node-gyp@npm:11.2.0": "12.4.0",
|
||||
"node-gyp@npm:11.5.0": "12.4.0",
|
||||
"node-gyp@npm:latest": "12.4.0",
|
||||
"node-gyp@npm:8.x": "12.4.0",
|
||||
"node-forge@npm:^1": "1.4.0",
|
||||
"node-forge@npm:^1.3.0": "1.4.0",
|
||||
"moment@npm:^2.8.4": "2.30.1",
|
||||
"node-gyp@npm:^9.0.0": "11.2.0",
|
||||
"node-forge@npm:^1": "1.3.2",
|
||||
"node-forge@npm:^1.3.0": "1.3.2",
|
||||
"moment@npm:^2.8.4": "2.29.4",
|
||||
"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",
|
||||
@@ -148,8 +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",
|
||||
"wasm-pack@npm:0.13.1": "patch:wasm-pack@npm%3A0.13.1#~/.yarn/patches/wasm-pack-npm-0.13.1-2a81da84b4.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"
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ class ResourceServer {
|
||||
this.logger().error('Resource server:', error);
|
||||
});
|
||||
|
||||
this.server_.listen(this.port_, '127.0.0.1');
|
||||
this.server_.listen(this.port_);
|
||||
|
||||
enableServerDestroy(this.server_);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { _ } from '@joplin/lib/locale';
|
||||
import time from '@joplin/lib/time';
|
||||
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||
import * as readline from 'readline';
|
||||
import { Stream, Writable } from 'stream';
|
||||
import { Writable } from 'stream';
|
||||
import BaseCommand from './base-command';
|
||||
import yargParser = require('yargs-parser');
|
||||
const stringPadding = require('string-padding');
|
||||
@@ -236,7 +236,7 @@ cliUtils.prompt = function(_initialText = '', promptString = ':', options: Promp
|
||||
if (!this.muted) process.stdout.write(chunk, encoding);
|
||||
callback();
|
||||
},
|
||||
} as Stream.WritableOptions<Writable>) as MutableStdout;
|
||||
}) as MutableStdout;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, noteLockCipherTextStandIn } 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: noteLockCipherTextStandIn(), 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 = '';
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ class Command extends BaseCommand {
|
||||
}
|
||||
});
|
||||
|
||||
inputStream.on('error', (error: Error) => {
|
||||
inputStream.on('error', (error) => {
|
||||
if (!isSettled) {
|
||||
isSettled = true;
|
||||
reject(error);
|
||||
|
||||
@@ -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, noteLockCipherTextStandIn } 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: noteLockCipherTextStandIn(), 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, noteLockCipherTextStandIn } 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: noteLockCipherTextStandIn(), 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> = {
|
||||
|
||||
@@ -18,8 +18,6 @@ import { checkIfLoginWasSuccessful, generateApplicationConfirmUrl } from '@jopli
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { uuidgen } from '@joplin/lib/uuid';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive';
|
||||
import SyncTargetDropbox from '@joplin/lib/SyncTargetDropbox';
|
||||
|
||||
const logger = Logger.create('command-sync');
|
||||
|
||||
@@ -59,7 +57,7 @@ class Command extends BaseCommand {
|
||||
|
||||
if (this.syncTargetId_ === 3 || this.syncTargetId_ === 4) {
|
||||
// OneDrive
|
||||
this.oneDriveApiUtils_ = new OneDriveApiNodeUtils((syncTarget as SyncTargetOneDrive).api());
|
||||
this.oneDriveApiUtils_ = new OneDriveApiNodeUtils(syncTarget.api());
|
||||
const auth = await this.oneDriveApiUtils_.oauthDance({
|
||||
log: (s: string) => {
|
||||
return this.stdout(s);
|
||||
@@ -76,7 +74,7 @@ class Command extends BaseCommand {
|
||||
return true;
|
||||
} else if (syncTargetMd.name === 'dropbox') {
|
||||
// Dropbox
|
||||
const api = await (syncTarget as SyncTargetDropbox).api();
|
||||
const api = await syncTarget.api();
|
||||
const loginUrl = api.loginUrl();
|
||||
this.stdout(_('To allow Joplin to synchronise with Dropbox, please follow the steps below:'));
|
||||
this.stdout(_('Step 1: Open this URL in your browser to authorise the application:'));
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "3.7.1",
|
||||
"version": "3.7.0",
|
||||
"bin": "./main.js",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/lib": "~3.7",
|
||||
@@ -48,7 +48,7 @@
|
||||
"chalk": "4.1.2",
|
||||
"compare-version": "0.1.2",
|
||||
"file-type": "16.5.4",
|
||||
"fs-extra": "11.3.6",
|
||||
"fs-extra": "11.3.4",
|
||||
"html-entities": "1.4.0",
|
||||
"keytar": "7.9.0",
|
||||
"md5": "2.3.0",
|
||||
@@ -57,13 +57,13 @@
|
||||
"proper-lockfile": "4.1.2",
|
||||
"redux": "4.2.1",
|
||||
"server-destroy": "1.0.1",
|
||||
"sharp": "0.35.0",
|
||||
"sharp": "0.34.5",
|
||||
"sprintf-js": "1.1.3",
|
||||
"sqlite3": "5.1.6",
|
||||
"string-padding": "1.0.2",
|
||||
"strip-ansi": "6.0.1",
|
||||
"tcp-port-used": "1.0.3",
|
||||
"terminal-kit": "3.1.4",
|
||||
"tcp-port-used": "1.0.2",
|
||||
"terminal-kit": "3.1.2",
|
||||
"tkwidgets": "0.5.27",
|
||||
"url-parse": "1.5.10",
|
||||
"word-wrap": "1.2.5",
|
||||
@@ -73,7 +73,7 @@
|
||||
"@joplin/tools": "~3.7",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "22.19.21",
|
||||
"@types/node": "18.19.130",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
|
||||
@@ -2,9 +2,7 @@ import shim from '@joplin/lib/shim';
|
||||
import * as os from 'os';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { filename } from '@joplin/lib/path-utils';
|
||||
import { dirname } from 'path';
|
||||
import HtmlToMd, { ParseOptions } from '@joplin/lib/HtmlToMd';
|
||||
import MdToHtml from '@joplin/renderer/MdToHtml';
|
||||
|
||||
describe('HtmlToMd', () => {
|
||||
|
||||
@@ -131,54 +129,4 @@ describe('HtmlToMd', () => {
|
||||
expect(collapsedResult).toContain('\n\n');
|
||||
});
|
||||
|
||||
it('should not re-read the rows of a table for every row', async () => {
|
||||
// Under domino, each access to `table.rows` walks the whole table, so reading it once per row made
|
||||
// converting large tables quadratic: https://github.com/laurent22/joplin/issues/16894
|
||||
const resolveFrom = (moduleName: string, fromPath: string) => require.resolve(moduleName, { paths: [dirname(fromPath)] });
|
||||
const domino: { createDocument: (html: string)=> Document } = require(resolveFrom('@mixmark-io/domino', resolveFrom('@joplin/turndown', require.resolve('@joplin/lib/HtmlToMd'))));
|
||||
|
||||
const rowCount = 100;
|
||||
const rows = Array.from({ length: rowCount }, (_, i) => `<tr><td>${i}</td><td>b</td><td>c</td></tr>`).join('');
|
||||
const root = domino.createDocument(`<div><table><thead><tr><th>A</th><th>B</th><th>C</th></tr></thead><tbody>${rows}</tbody></table></div>`).querySelector('div');
|
||||
// Turndown converts a clone of the element it's given, which wouldn't have the counter below
|
||||
Object.defineProperty(root, 'cloneNode', { value: () => root });
|
||||
|
||||
const table = root.querySelector('table');
|
||||
const getRows = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(table), 'rows').get;
|
||||
let rowsReadCount = 0;
|
||||
Object.defineProperty(table, 'rows', { get: () => {
|
||||
rowsReadCount++;
|
||||
return getRows.call(table);
|
||||
} });
|
||||
|
||||
const markdown = new HtmlToMd().parse(root);
|
||||
expect(markdown.split('\n')).toHaveLength(rowCount + 2);
|
||||
expect(rowsReadCount).toBeLessThan(rowCount);
|
||||
});
|
||||
|
||||
// Covers how newlines are carried between children when joining them, which was rewritten to fix
|
||||
// https://github.com/laurent22/joplin/issues/16895
|
||||
it.each([
|
||||
['<ul><li>a</li><li></li><li>b</li></ul>', '- a\n\n- b'],
|
||||
['<span><p>a</p></span>b', 'a\n\nb'],
|
||||
['<pre><code><span>line 1</span>\n\n\n<span>line 2</span></code></pre>', '```\nline 1\n\n\nline 2\n```'],
|
||||
])('should join the Markdown of child elements: %j', (html, expected) => {
|
||||
expect(new HtmlToMd().parse(html)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['inline code ending with a space at the end of a heading', '<h2><code>npm</code> install <code>a </code></h2>', 'a\u00A0'],
|
||||
['a heading that only contains inline code with a space', '<h2><code> </code></h2>', '\u00A0'],
|
||||
])('should not change %s when the note is saved again', async (_title, html, codeText) => {
|
||||
const htmlToMd = new HtmlToMd();
|
||||
const mdToHtml = new MdToHtml();
|
||||
|
||||
const firstSave = htmlToMd.parse(html);
|
||||
const reopened = await mdToHtml.render(firstSave, null, { bodyOnly: true });
|
||||
const secondSave = htmlToMd.parse(reopened.html);
|
||||
|
||||
expect(reopened.html).toContain(`>${codeText}</code>`);
|
||||
expect(secondSave).toBe(firstSave);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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,29 +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="20260914T100000Z" application="Evernote" version="10.0">
|
||||
<note>
|
||||
<title>Unescaped ampersand</title>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
|
||||
<en-note><div>Mobilize & Measure</div></en-note>
|
||||
]]>
|
||||
</content>
|
||||
<created>20260914T100000Z</created>
|
||||
<updated>20260914T100000Z</updated>
|
||||
<note-attributes>
|
||||
<application-data key="evernote.caption">Phase 1: Mobilize & Measure, which involves forming an advocacy group.</application-data>
|
||||
</note-attributes>
|
||||
</note>
|
||||
<note>
|
||||
<title>Note after the bad one</title>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
|
||||
<en-note><div>This note must still be imported</div></en-note>
|
||||
]]>
|
||||
</content>
|
||||
<created>20260914T100000Z</created>
|
||||
<updated>20260914T100000Z</updated>
|
||||
</note>
|
||||
</en-export>
|
||||
@@ -1 +1 @@
|
||||
<a>test</a>
|
||||
<a data-from-md href='#'>test</a>
|
||||
@@ -1,5 +0,0 @@
|
||||
<p>Headings with nonbreaking spaces should be preserved:</p>
|
||||
<h2> </h2>
|
||||
<h2> </h2>
|
||||
<h3> Leading space</h3>
|
||||
<h2><code>a b</code></h2>
|
||||
@@ -1,9 +0,0 @@
|
||||
Headings with nonbreaking spaces should be preserved:
|
||||
|
||||
##
|
||||
|
||||
##
|
||||
|
||||
### Leading space
|
||||
|
||||
## `a b`
|
||||
@@ -8,12 +8,8 @@ import { ExportModule } from '@joplin/lib/services/interop/Module';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import bridge from './services/bridge';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import { unlockNoteLockSession } from '@joplin/lib/services/noteLock/noteLockPrompts';
|
||||
import noteLockPrompts from './utils/noteLockPrompts';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { friendlySafeFilename } from '@joplin/lib/path-utils';
|
||||
import time from '@joplin/lib/time';
|
||||
import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
|
||||
@@ -40,8 +36,6 @@ export default class InteropServiceHelper {
|
||||
format: ExportModuleOutputFormat.Html,
|
||||
target: FileSystemItem.File,
|
||||
sourceNoteIds: [noteId],
|
||||
// The PDF export and printing ask first.
|
||||
decryptLockedNotes: true,
|
||||
customCss: '', ...exportOptions };
|
||||
|
||||
const service = InteropService.instance();
|
||||
@@ -199,30 +193,7 @@ export default class InteropServiceHelper {
|
||||
return `${filename}.${fileExtension}`;
|
||||
}
|
||||
|
||||
// Decrypting exports write locked notes out as plain text, so the user is asked even with the session unlocked,
|
||||
// which only skips the password.
|
||||
public static async confirmLockedNoteExport(dispatch: Dispatch, noteIds: string[], folderIds: string[] = null) {
|
||||
if (!isNoteLockEnabled()) return 'export';
|
||||
let hasLockedNotes = false;
|
||||
if (noteIds?.length) {
|
||||
hasLockedNotes = (await Note.byIds(noteIds, { fields: ['is_locked'] })).some(note => !!note.is_locked);
|
||||
} else if (folderIds?.length) {
|
||||
hasLockedNotes = await Note.hasLockedNoteInFolderTrees(folderIds);
|
||||
} else {
|
||||
// Same scope as the export of everything: notebooks not in the trash.
|
||||
hasLockedNotes = await Note.hasLockedNoteInFolderTrees(await Folder.childrenIds(''));
|
||||
}
|
||||
if (!hasLockedNotes) return 'export';
|
||||
|
||||
const answer = bridge().showMessageBox(_('Some notes are locked and this export format requires them to be unlocked. Would you like to export these notes unprotected, or export without these notes?'), {
|
||||
buttons: [_('Export unprotected'), _('Export without locked notes'), _('Cancel')],
|
||||
});
|
||||
if (answer === 1) return 'skipLocked';
|
||||
if (answer === 0 && await unlockNoteLockSession(noteLockPrompts(dispatch))) return 'export';
|
||||
return 'cancel';
|
||||
}
|
||||
|
||||
public static async export(dispatch: Dispatch, module: ExportModule, options: ExportNoteOptions = null) {
|
||||
public static async export(_dispatch: Dispatch, module: ExportModule, options: ExportNoteOptions = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
let path = null;
|
||||
@@ -243,18 +214,6 @@ export default class InteropServiceHelper {
|
||||
|
||||
if (Array.isArray(path)) path = path[0];
|
||||
|
||||
// Backups keep locked notes encrypted, so only the decrypting formats ask.
|
||||
const isBackup = module.format === ExportModuleOutputFormat.Raw || module.format === ExportModuleOutputFormat.Jex;
|
||||
const lockedNoteChoice = isBackup ? 'export' : await this.confirmLockedNoteExport(dispatch, options.sourceNoteIds, options.sourceFolderIds);
|
||||
if (lockedNoteChoice === 'cancel') return;
|
||||
|
||||
let sourceNoteIds = options.sourceNoteIds;
|
||||
// A selection left with no note would make a single-file export fail on the file it never wrote.
|
||||
if (sourceNoteIds?.length && lockedNoteChoice === 'skipLocked') {
|
||||
sourceNoteIds = (await Note.byIds(sourceNoteIds, { fields: ['id', 'is_locked'] })).filter(note => !note.is_locked).map(note => note.id);
|
||||
if (!sourceNoteIds.length) return;
|
||||
}
|
||||
|
||||
void CommandService.instance().execute('showModalMessage', _('Exporting to "%s" as "%s" format. Please wait...', path, module.format));
|
||||
|
||||
const exportOptions: ExportOptions = {};
|
||||
@@ -266,34 +225,20 @@ export default class InteropServiceHelper {
|
||||
exportOptions.target = module.target;
|
||||
exportOptions.includeConflicts = !!options.includeConflicts;
|
||||
if (options.sourceFolderIds) exportOptions.sourceFolderIds = options.sourceFolderIds;
|
||||
if (sourceNoteIds) exportOptions.sourceNoteIds = sourceNoteIds;
|
||||
exportOptions.skipLockedNotes = lockedNoteChoice === 'skipLocked';
|
||||
exportOptions.decryptLockedNotes = lockedNoteChoice === 'export';
|
||||
if (options.sourceNoteIds) exportOptions.sourceNoteIds = options.sourceNoteIds;
|
||||
|
||||
const service = InteropService.instance();
|
||||
let keepModalOpen = false;
|
||||
|
||||
try {
|
||||
const result = await service.export(exportOptions);
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('Export result: ', result);
|
||||
if (result.warnings.length) {
|
||||
keepModalOpen = true;
|
||||
const message = [
|
||||
_('Completed with warnings:'),
|
||||
'',
|
||||
_('If any files are reported missing, download all relevant attachments to this device and try again.'),
|
||||
'',
|
||||
...result.warnings,
|
||||
].join('\n');
|
||||
void CommandService.instance().execute('showModalMessage', message, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
bridge().showErrorMessageBox(_('Could not export notes: %s', error.message));
|
||||
}
|
||||
|
||||
if (!keepModalOpen) void CommandService.instance().execute('hideModalMessage');
|
||||
void CommandService.instance().execute('hideModalMessage');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,25 +1,9 @@
|
||||
import { ChatRole, ChatToolCall, ChatToolMessage } from '@joplin/lib/services/ai/types';
|
||||
import { AiChatMessage, AppState, createAppDefaultWindowState } from './app.reducer';
|
||||
import appReducer, { createAppDefaultState } from './app.reducer';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
|
||||
describe('app.reducer', () => {
|
||||
it('should clear a deleted conversation from matching windows and keep other conversations', () => {
|
||||
const messages: AiChatMessage[] = [{ id: 'question', createdTime: 1, role: 'user', text: 'Question', raw: [], noteId: '', noteTitle: '' }];
|
||||
const state = createAppDefaultState({});
|
||||
state.aiChatConversationId = 'deleted';
|
||||
state.aiChatMessages = messages;
|
||||
state.backgroundWindows = {
|
||||
second: { ...createAppDefaultWindowState(), windowId: 'second', aiChatConversationId: 'deleted', aiChatMessages: messages },
|
||||
third: { ...createAppDefaultWindowState(), windowId: 'third', aiChatConversationId: 'other', aiChatMessages: messages },
|
||||
};
|
||||
const result = appReducer(state, { type: 'AI_CHAT_DELETE', conversationId: 'deleted' });
|
||||
expect(result.aiChatConversationId).toBeNull();
|
||||
expect(result.aiChatMessages).toEqual([]);
|
||||
expect(result.backgroundWindows.second.aiChatConversationId).toBeNull();
|
||||
expect(result.backgroundWindows.second.aiChatMessages).toEqual([]);
|
||||
expect(result.backgroundWindows.third.aiChatConversationId).toBe('other');
|
||||
expect(result.backgroundWindows.third.aiChatMessages).toEqual(messages);
|
||||
});
|
||||
|
||||
it('should handle DIALOG_OPEN', async () => {
|
||||
const state: AppState = createAppDefaultState({});
|
||||
@@ -123,15 +107,11 @@ describe('app.reducer', () => {
|
||||
it('should build AI chat history', () => {
|
||||
let state: AppState = {
|
||||
...createAppDefaultState({}),
|
||||
aiChatConversationId: 'chat-1',
|
||||
};
|
||||
|
||||
let idCounter = 0;
|
||||
const buildMessage = (role: ChatRole.User | ChatRole.Assistant, content: string, toolCalls: ChatToolCall[]) => ({
|
||||
id: `id-${idCounter++}`,
|
||||
createdTime: 1,
|
||||
noteId: 'note-a',
|
||||
noteTitle: 'A',
|
||||
role,
|
||||
text: content,
|
||||
raw: [
|
||||
@@ -145,12 +125,12 @@ describe('app.reducer', () => {
|
||||
|
||||
state = appReducer(state, {
|
||||
type: 'AI_CHAT_APPEND',
|
||||
conversationId: 'chat-1',
|
||||
windowId: defaultWindowId,
|
||||
message: buildMessage(ChatRole.User, 'Test', []),
|
||||
});
|
||||
state = appReducer(state, {
|
||||
type: 'AI_CHAT_APPEND',
|
||||
conversationId: 'chat-1',
|
||||
windowId: defaultWindowId,
|
||||
message: buildMessage(
|
||||
ChatRole.Assistant,
|
||||
'Testing',
|
||||
@@ -159,21 +139,28 @@ describe('app.reducer', () => {
|
||||
});
|
||||
state = appReducer(state, {
|
||||
type: 'AI_CHAT_ADD_TOOL_RESULT',
|
||||
conversationId: 'chat-1',
|
||||
windowId: defaultWindowId,
|
||||
toolCall: ({
|
||||
role: ChatRole.Tool,
|
||||
toolName: 'testTool',
|
||||
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 },
|
||||
]);
|
||||
|
||||
|
||||
state = appReducer(state, {
|
||||
type: 'AI_CHAT_RESET',
|
||||
windowId: defaultWindowId,
|
||||
});
|
||||
|
||||
expect(state.aiChatMessages).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,18 +5,16 @@ import iterateItems from './gui/ResizableLayout/utils/iterateItems';
|
||||
import { LayoutItem } from './gui/ResizableLayout/utils/types';
|
||||
import validateLayout from './gui/ResizableLayout/utils/validateLayout';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types';
|
||||
import { ChatMessage } from '@joplin/lib/services/ai/types';
|
||||
|
||||
const logger = Logger.create('app.reducer');
|
||||
|
||||
export interface AiChatMessage {
|
||||
id: string;
|
||||
createdTime: number;
|
||||
noteId: string;
|
||||
noteTitle: string;
|
||||
role: 'user' | 'assistant' | 'error' | 'separator';
|
||||
text: string;
|
||||
hide?: boolean;
|
||||
editsApplied?: number;
|
||||
editsMissed?: number;
|
||||
|
||||
// The raw message(s) corresponding to this event
|
||||
raw: ChatMessage[];
|
||||
@@ -81,7 +79,6 @@ export interface AppWindowState extends WindowState {
|
||||
// In window state so the conversation survives panel hide/show (the
|
||||
// layout container can swap component types and unmount the panel).
|
||||
aiChatMessages: AiChatMessage[];
|
||||
aiChatConversationId: string|null;
|
||||
// Layout for secondary windows
|
||||
secondaryWindowLayout: LayoutItem|null;
|
||||
}
|
||||
@@ -100,7 +97,6 @@ export interface AppState extends State, AppWindowState {
|
||||
layoutMoveMode: boolean;
|
||||
startupPluginsLoaded: boolean;
|
||||
modalOverlayMessage: string|null;
|
||||
modalOverlayHasCloseButton: boolean;
|
||||
|
||||
// Extra reducer keys go here
|
||||
mainLayout: LayoutItem;
|
||||
@@ -120,7 +116,6 @@ export const createAppDefaultWindowState = (): AppWindowState => {
|
||||
whiteboardForceMarkdown: {},
|
||||
activeNoteIsWhiteboard: false,
|
||||
aiChatMessages: [],
|
||||
aiChatConversationId: null,
|
||||
secondaryWindowLayout: null,
|
||||
};
|
||||
};
|
||||
@@ -144,7 +139,6 @@ export function createAppDefaultState(resourceEditWatcherDefaultState: Partial<A
|
||||
startupPluginsLoaded: false,
|
||||
isResettingLayout: false,
|
||||
modalOverlayMessage: null,
|
||||
modalOverlayHasCloseButton: false,
|
||||
aiStatus: defaultAiStatus(),
|
||||
...resourceEditWatcherDefaultState,
|
||||
};
|
||||
@@ -306,49 +300,56 @@ export default function(state: AppState, action: any) {
|
||||
}
|
||||
|
||||
case 'WHITEBOARD_ACTIVE_NOTE_SET':
|
||||
newState = withWindowStateUpdated(
|
||||
state, action.windowId, 'activeNoteIsWhiteboard', () => !!action.value,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'AI_CHAT_DELETE':
|
||||
newState = produce(state, draft => {
|
||||
for (const windowState of stateUtils.allWindowStates(draft)) {
|
||||
if (windowState.aiChatConversationId !== action.conversationId) continue;
|
||||
windowState.aiChatConversationId = null;
|
||||
windowState.aiChatMessages = [];
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case 'AI_CHAT_OPEN':
|
||||
newState = produce(state, draft => {
|
||||
const openConversation = action.conversationId && stateUtils.allWindowStates(state)
|
||||
.find(windowState => windowState.aiChatConversationId === action.conversationId);
|
||||
const windowState = stateUtils.windowStateById(draft, action.windowId);
|
||||
windowState.aiChatConversationId = action.conversationId;
|
||||
windowState.aiChatMessages = openConversation ? openConversation.aiChatMessages : action.messages;
|
||||
});
|
||||
newState = {
|
||||
...state,
|
||||
activeNoteIsWhiteboard: !!action.value,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'AI_CHAT_APPEND':
|
||||
newState = withWindowStateUpdated(
|
||||
state, action.windowId, 'aiChatMessages', messages => [...messages, action.message as AiChatMessage],
|
||||
);
|
||||
break;
|
||||
|
||||
case 'AI_CHAT_ADD_TOOL_RESULT':
|
||||
case 'AI_CHAT_REMOVE':
|
||||
newState = produce(state, draft => {
|
||||
for (const windowState of stateUtils.allWindowStates(draft)) {
|
||||
if (windowState.aiChatConversationId !== action.conversationId) continue;
|
||||
if (action.type === 'AI_CHAT_APPEND') {
|
||||
windowState.aiChatMessages.push(action.message);
|
||||
} else if (action.type === 'AI_CHAT_REMOVE') {
|
||||
windowState.aiChatMessages = windowState.aiChatMessages.filter(message => message.id !== action.id);
|
||||
} else {
|
||||
const message = windowState.aiChatMessages.find(message => message.raw.some(entry =>
|
||||
entry.role === ChatRole.Assistant && entry.toolCalls?.some(call => call.callId === action.toolCall.toolCallId),
|
||||
));
|
||||
message?.raw.push(action.toolCall);
|
||||
newState = withWindowStateUpdated(
|
||||
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,
|
||||
],
|
||||
};
|
||||
|
||||
return [...messages.slice(0, messages.length - 1), lastMessage];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return messages;
|
||||
},
|
||||
);
|
||||
break;
|
||||
|
||||
case 'AI_CHAT_REMOVE':
|
||||
newState = withWindowStateUpdated(
|
||||
state, action.windowId, 'aiChatMessages', messages => messages.filter(m => m.id !== action.id),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'AI_CHAT_RESET':
|
||||
newState = withWindowStateUpdated(
|
||||
state, action.windowId, 'aiChatMessages', (): AiChatMessage[] => [],
|
||||
);
|
||||
break;
|
||||
|
||||
case 'AI_STATUS_UPDATE':
|
||||
@@ -398,15 +399,11 @@ export default function(state: AppState, action: any) {
|
||||
}
|
||||
|
||||
case 'SHOW_MODAL_MESSAGE':
|
||||
newState = {
|
||||
...newState,
|
||||
modalOverlayMessage: action.message,
|
||||
modalOverlayHasCloseButton: !!action.hasCloseButton,
|
||||
};
|
||||
newState = { ...newState, modalOverlayMessage: action.message };
|
||||
break;
|
||||
|
||||
case 'HIDE_MODAL_MESSAGE':
|
||||
newState = { ...newState, modalOverlayMessage: null, modalOverlayHasCloseButton: false };
|
||||
newState = { ...newState, modalOverlayMessage: null };
|
||||
break;
|
||||
|
||||
case 'NOTE_FILE_WATCHER_ADD':
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import BaseApplication, { shouldPreserveSelectedNoteOnSmartFilterSelect } from '@joplin/lib/BaseApplication';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import reducer, { defaultState, defaultWindowId, State } from '@joplin/lib/reducer';
|
||||
import { ALL_NOTES_FILTER_ID } from '@joplin/lib/reserved-ids';
|
||||
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 waitFor from '@joplin/lib/testing/waitFor';
|
||||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||
import { appendFile, 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 = (watchedNoteFiles: string[] = []) => {
|
||||
const middleware = app().generalMiddlewareFn()({ getState: () => ({ watchedNoteFiles }) });
|
||||
return middleware(jest.fn());
|
||||
};
|
||||
|
||||
describe('app', () => {
|
||||
const watcher = ExternalEditWatcher.instance();
|
||||
const originalTextEncoder = globalThis.TextEncoder;
|
||||
const originalNoteDispatch = Note.dispatch;
|
||||
let baseMiddlewareMock: jest.SpyInstance;
|
||||
|
||||
test.each([
|
||||
['regular', { deleted_time: 0, is_conflict: 0 }, true],
|
||||
['trashed', { deleted_time: 1, is_conflict: 0 }, false],
|
||||
['conflict', { deleted_time: 0, is_conflict: 1 }, false],
|
||||
])('should determine whether to preserve a %s note when selecting all notes', (_noteType, noteProperties, expected) => {
|
||||
watcher.initialize(jest.fn(), jest.fn());
|
||||
const note = { id: 'note-id', ...noteProperties } as NoteEntity;
|
||||
const state = { ...defaultState, notes: [note], selectedNoteIds: [note.id] };
|
||||
|
||||
expect(shouldPreserveSelectedNoteOnSmartFilterSelect(state, ALL_NOTES_FILTER_ID)).toBe(expected);
|
||||
});
|
||||
|
||||
test('should not apply a note refresh after the active window changes', async () => {
|
||||
const secondaryState = {
|
||||
...defaultState,
|
||||
windowId: 'secondary-window',
|
||||
notesParentType: 'Folder',
|
||||
selectedFolderId: 'secondary-folder',
|
||||
selectedNoteIds: ['secondary-note'],
|
||||
} as State;
|
||||
let activeState = secondaryState;
|
||||
const dispatch = jest.fn();
|
||||
const storeMock = jest.spyOn(app(), 'store').mockReturnValue({
|
||||
dispatch,
|
||||
getState: () => activeState,
|
||||
} as unknown as ReturnType<ReturnType<typeof app>['store']>);
|
||||
const previewsMock = jest.spyOn(Note, 'previews').mockImplementation(async () => {
|
||||
activeState = { ...defaultState, windowId: defaultWindowId } as State;
|
||||
return [{ id: 'secondary-note' }] as NoteEntity[];
|
||||
});
|
||||
|
||||
try {
|
||||
await app().refreshNotes(secondaryState, true);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
previewsMock.mockRestore();
|
||||
storeMock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('should not apply a note refresh after a newer refresh has started', async () => {
|
||||
const state = { ...defaultState, notesParentType: 'Folder', selectedFolderId: 'folder' } as State;
|
||||
const dispatch = jest.fn();
|
||||
const storeMock = jest.spyOn(app(), 'store').mockReturnValue({
|
||||
dispatch,
|
||||
getState: () => state,
|
||||
} as unknown as ReturnType<ReturnType<typeof app>['store']>);
|
||||
let resolveSlowPreviews: (notes: NoteEntity[])=> void = null;
|
||||
const previewsMock = jest.spyOn(Note, 'previews')
|
||||
.mockImplementationOnce(() => new Promise(resolve => { resolveSlowPreviews = resolve; }))
|
||||
.mockResolvedValueOnce([{ id: 'fast-note' }]);
|
||||
|
||||
try {
|
||||
const slowRefresh = app().refreshNotes(state);
|
||||
const fastRefresh = (async () => {
|
||||
await app().refreshNotes(state);
|
||||
resolveSlowPreviews([{ id: 'slow-note' }]);
|
||||
})();
|
||||
await Promise.all([slowRefresh, fastRefresh]);
|
||||
|
||||
const noteUpdates = dispatch.mock.calls.map(call => call[0]).filter(action => action.type === 'NOTE_UPDATE_ALL');
|
||||
expect(noteUpdates.map(action => action.notes)).toEqual([[{ id: 'fast-note' }]]);
|
||||
} finally {
|
||||
previewsMock.mockRestore();
|
||||
storeMock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('should not let a refresh for an inactive window cancel the active window refresh', async () => {
|
||||
const state = { ...defaultState, notesParentType: 'Folder', selectedFolderId: 'folder' } as State;
|
||||
const dispatch = jest.fn();
|
||||
const storeMock = jest.spyOn(app(), 'store').mockReturnValue({
|
||||
dispatch,
|
||||
getState: () => state,
|
||||
} as unknown as ReturnType<ReturnType<typeof app>['store']>);
|
||||
const previewsMock = jest.spyOn(Note, 'previews').mockResolvedValue([]);
|
||||
|
||||
try {
|
||||
await Promise.all([app().refreshNotes(state), app().refreshNotes({ ...state, windowId: 'inactive-window' })]);
|
||||
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'NOTE_UPDATE_ALL' }));
|
||||
} finally {
|
||||
previewsMock.mockRestore();
|
||||
storeMock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
['the active window', true, [[expect.objectContaining({ windowId: defaultWindowId }), false, '']]],
|
||||
['a background window', false, []],
|
||||
])('should only refresh notes when the active window is closed (closing %s)', async (_name, focusSecondaryWindow, expectedRefreshCalls) => {
|
||||
let state = reducer(defaultState, { type: 'WINDOW_OPEN', windowId: 'secondary-window', folderId: 'folder', noteId: 'note' });
|
||||
if (focusSecondaryWindow) state = reducer(state, { type: 'WINDOW_FOCUS', windowId: 'secondary-window' });
|
||||
const store = { getState: () => state, dispatch: jest.fn() };
|
||||
const next = (action: unknown) => { state = reducer(state, action); };
|
||||
const refreshNotesMock = jest.spyOn(app(), 'refreshNotes').mockResolvedValue(undefined);
|
||||
baseMiddlewareMock.mockRestore();
|
||||
|
||||
try {
|
||||
await (BaseApplication.prototype as unknown as {
|
||||
generalMiddleware: (store: unknown, next: (action: unknown)=> unknown, action: unknown)=> Promise<unknown>;
|
||||
}).generalMiddleware.call(app(), store, next, { type: 'WINDOW_CLOSE', windowId: 'secondary-window' });
|
||||
expect(refreshNotesMock.mock.calls).toEqual(expectedRefreshCalls);
|
||||
} finally {
|
||||
refreshNotesMock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
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 drop pending external edits when a synced note arrives locked', async () => {
|
||||
const { originalNote, externalFilePath } = await startExternalEditing();
|
||||
const lockedNote = await Note.save({
|
||||
...originalNote,
|
||||
body: 'Locked note ciphertext',
|
||||
is_locked: 1,
|
||||
}, { changeSource: ItemChange.SOURCE_SYNC });
|
||||
|
||||
await appendFile(externalFilePath, ' (plaintext edit)');
|
||||
const changeEventQueue = (watcher as unknown as { changeEventQueue_: AsyncActionQueue<unknown> }).changeEventQueue_;
|
||||
await waitFor(() => {
|
||||
expect(changeEventQueue.isEmpty).toBe(false);
|
||||
});
|
||||
|
||||
const handleDesktopAction = createDesktopActionHandler([originalNote.id]);
|
||||
await handleDesktopAction({
|
||||
type: 'NOTE_UPDATE_ONE',
|
||||
changeSource: ItemChange.SOURCE_SYNC,
|
||||
changedFields: ['body', 'is_locked'],
|
||||
note: lockedNote,
|
||||
});
|
||||
await changeEventQueue.waitForAllDone();
|
||||
|
||||
expect(watcher.noteIsWatched(originalNote)).toBe(false);
|
||||
expect((await Note.load(originalNote.id)).body).toBe('Locked note ciphertext');
|
||||
});
|
||||
|
||||
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,17 +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);
|
||||
}
|
||||
|
||||
// Like a lock made on this device (see enableNoteEncryption), a lock that arrives with a sync ends the note's
|
||||
// external editing, which the UI cannot stop for a locked note. The lock is already saved here, so pending
|
||||
// plaintext edits are dropped rather than saved over the ciphertext.
|
||||
if (action.type === 'NOTE_UPDATE_ONE' && action.note.is_locked && store.getState().watchedNoteFiles.includes(action.note.id)) {
|
||||
await ExternalEditWatcher.instance().stopWatching(action.note.id, false);
|
||||
}
|
||||
|
||||
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'featureFlag.autoUpdaterServiceEnabled' || action.type === 'SETTING_UPDATE_ALL') {
|
||||
if (Setting.value('featureFlag.autoUpdaterServiceEnabled')) this.setupAutoUpdaterService();
|
||||
}
|
||||
@@ -181,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);
|
||||
}
|
||||
@@ -683,7 +658,7 @@ class Application extends BaseApplication {
|
||||
setTimeout(() => {
|
||||
// Schedule sync with a delay of 0 and wrap with the desired timeout, as shim.setTimeout may not fire on first run or after an upgrade
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
void reg.scheduleSync(0).finally(() => {
|
||||
void reg.scheduleSync(0).then(() => {
|
||||
// Wait for the first sync before updating the notifications, since synchronisation
|
||||
// might change the notifications.
|
||||
void AlarmService.updateAllNotifications();
|
||||
@@ -797,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;
|
||||
|
||||
@@ -16,8 +16,6 @@ import { KB } from '@joplin/utils/bytes';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import { execCommand } from '@joplin/utils';
|
||||
|
||||
const allowedExternalProtocols = ['http:', 'https:', 'mailto:', 'joplin:'];
|
||||
|
||||
interface LastSelectedPath {
|
||||
file: string;
|
||||
directory: string;
|
||||
@@ -497,12 +495,6 @@ export class Bridge {
|
||||
if (protocol === 'file:') {
|
||||
await this.openItem(url);
|
||||
} else {
|
||||
// Some callers pass untrusted note content, and this reaches the OS
|
||||
// URI dispatcher, where a dangerous scheme handler can mean RCE.
|
||||
if (!allowedExternalProtocols.includes(protocol)) {
|
||||
console.warn(`Bridge: openExternal: rejected URL with disallowed scheme "${protocol}"`);
|
||||
return;
|
||||
}
|
||||
return shell.openExternal(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<!-- Cloud Offline Outline from Ionicons. See readme/licenses.md under "ionicon icons" for attribution and MIT license terms. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M93.72 183.25C49.49 198.05 16 233.1 16 288c0 66 54 112 120 112h184.37M467.82 377.74C485.24 363.3 496 341.61 496 312c0-59.82-53-85.76-96-88-8.89-89.54-71-144-144-144-26.16 0-48.79 6.93-67.6 18.14" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32"/><path d="M448 448 64 64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32"/></svg>
|
||||
|
Before Width: | Height: | Size: 628 B |
@@ -12,7 +12,6 @@ import * as openNoteInNewWindow from './openNoteInNewWindow';
|
||||
import * as openPrimaryAppInstance from './openPrimaryAppInstance';
|
||||
import * as openProfileDirectory from './openProfileDirectory';
|
||||
import * as openSecondaryAppInstance from './openSecondaryAppInstance';
|
||||
import * as openSyncWizard from './openSyncWizard';
|
||||
import * as replaceMisspelling from './replaceMisspelling';
|
||||
import * as restoreNoteRevision from './restoreNoteRevision';
|
||||
import * as showProfileEditor from './showProfileEditor';
|
||||
@@ -40,7 +39,6 @@ const index: any[] = [
|
||||
openPrimaryAppInstance,
|
||||
openProfileDirectory,
|
||||
openSecondaryAppInstance,
|
||||
openSyncWizard,
|
||||
replaceMisspelling,
|
||||
restoreNoteRevision,
|
||||
showProfileEditor,
|
||||
|
||||
@@ -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,18 +18,14 @@ 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(),
|
||||
notes: [note],
|
||||
noteVisiblePanes: Setting.value('noteVisiblePanes'),
|
||||
editorCodeView: Setting.value('editor.codeView'),
|
||||
},
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'openSyncWizard',
|
||||
label: () => _('Open sync wizard'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext) => {
|
||||
context.dispatch({
|
||||
type: 'DIALOG_OPEN',
|
||||
name: 'syncWizard',
|
||||
});
|
||||
},
|
||||
// Prevents the user from attempting to open multiple copies of the sync wizard, which would throw
|
||||
enabledCondition: '!modalDialogVisible',
|
||||
};
|
||||
};
|
||||
@@ -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') : '';
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
@mixin chat-history-input {
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
color: var(--joplin-color);
|
||||
background-color: var(--joplin-background-color);
|
||||
border: 1px solid var(--joplin-divider-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-history {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 30px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
padding: 6px 10px 10px;
|
||||
font-weight: normal;
|
||||
background-color: var(--joplin-background-color);
|
||||
border-bottom: 1px solid var(--joplin-divider-color);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
|
||||
> .search {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> .icon {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
color: var(--joplin-color-faded);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .field {
|
||||
@include chat-history-input;
|
||||
width: 100%;
|
||||
padding: 6px 6px 6px 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conversation-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&.-current {
|
||||
font-weight: bold;
|
||||
background-color: var(--joplin-selected-color);
|
||||
}
|
||||
|
||||
&:hover:not(.-current) {
|
||||
background-color: var(--joplin-background-color-hover3);
|
||||
}
|
||||
|
||||
> .open {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
> .title-input {
|
||||
@include chat-history-input;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 6px 20px;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
> .actions {
|
||||
flex-shrink: 0;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
--chat-history-timestamp-display: none;
|
||||
}
|
||||
|
||||
&:hover > .actions,
|
||||
&:focus-within > .actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-link {
|
||||
display: flex;
|
||||
padding: 10px 20px;
|
||||
gap: 18px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
> .title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
> .timestamp {
|
||||
display: var(--chat-history-timestamp-display, initial);
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
color: var(--joplin-color-faded);
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-actions {
|
||||
display: none;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
|
||||
> button {
|
||||
display: flex;
|
||||
color: var(--joplin-color3);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { formatMsToRelativeTime } from '@joplin/utils/time';
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
updated_time: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
conversations: Conversation[];
|
||||
currentConversationId?: string|null;
|
||||
id: string;
|
||||
popupRef: React.Ref<HTMLDivElement>;
|
||||
onSearchChange: (search: string)=> void;
|
||||
onOpen: (conversationId: string)=> void;
|
||||
onRename: (conversation: Conversation, title: string)=> void;
|
||||
onDelete: (conversationId: string)=> void;
|
||||
onClose: ()=> void;
|
||||
}
|
||||
|
||||
const ChatHistory: React.FC<Props> = props => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [editingId, setEditingId] = useState<string|null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState('');
|
||||
|
||||
const startEditing = (conversation: Conversation) => {
|
||||
setEditingId(conversation.id);
|
||||
setDraftTitle(conversation.title);
|
||||
};
|
||||
|
||||
const stopEditing = () => setEditingId(null);
|
||||
|
||||
const onTitleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>, conversation: Conversation) => {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
stopEditing();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
props.onRename(conversation, draftTitle);
|
||||
stopEditing();
|
||||
};
|
||||
|
||||
const onPopupKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.nativeEvent.isComposing || event.key !== 'Escape') return;
|
||||
event.stopPropagation();
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return <div className='chat-history' id={props.id} ref={props.popupRef} onKeyDown={onPopupKeyDown}>
|
||||
<div className='conversation-search search'>
|
||||
<i className='icon icon-search' aria-hidden='true'/>
|
||||
<input
|
||||
className='field'
|
||||
type='search'
|
||||
autoFocus
|
||||
aria-label={_('Search conversations')}
|
||||
placeholder={_('Search conversations')}
|
||||
value={search}
|
||||
onChange={event => {
|
||||
setSearch(event.target.value);
|
||||
props.onSearchChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ul className='conversation-list' aria-label={_('Conversations')}>
|
||||
{props.conversations.map(conversation => {
|
||||
const current = conversation.id === props.currentConversationId;
|
||||
const title = conversation.title || _('(untitled)');
|
||||
const editing = conversation.id === editingId;
|
||||
return <li key={conversation.id} className={`conversation-row${current ? ' -current' : ''}`} aria-current={current ? 'true' : undefined}
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) props.onOpen(conversation.id);
|
||||
}}
|
||||
>
|
||||
{editing ? (
|
||||
<input
|
||||
className='title-input'
|
||||
type='text'
|
||||
autoFocus
|
||||
aria-label={_('Conversation title')}
|
||||
value={draftTitle}
|
||||
onChange={event => setDraftTitle(event.target.value)}
|
||||
onKeyDown={event => onTitleKeyDown(event, conversation)}
|
||||
onBlur={stopEditing}
|
||||
/>
|
||||
) : (
|
||||
<button className='conversation-link open' type='button' onClick={() => props.onOpen(conversation.id)}>
|
||||
<span className='title'>{title}</span>
|
||||
<span className='timestamp'>{formatMsToRelativeTime(conversation.updated_time)}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className='conversation-actions actions'>
|
||||
<button className='flat-button' type='button' title={_('Rename')} aria-label={_('Rename')} onClick={() => startEditing(conversation)}>
|
||||
<i className='fa fa-pen' aria-hidden='true'/>
|
||||
</button>
|
||||
<button className='flat-button' type='button' title={_('Delete')} aria-label={_('Delete')} onClick={() => props.onDelete(conversation.id)}>
|
||||
<i className='fas fa-trash' aria-hidden='true'/>
|
||||
</button>
|
||||
</div>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default ChatHistory;
|
||||
@@ -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>
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { act, fireEvent, render, waitFor, within } from '@testing-library/react';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import ChatConversation from '@joplin/lib/models/ChatConversation';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { runNoteChat } from '@joplin/lib/services/ai/noteChat';
|
||||
import { ChatRole } from '@joplin/lib/services/ai/types';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import appReducer, { AiChatMessage, createAppDefaultState, createAppDefaultWindowState } from '../../app.reducer';
|
||||
import ChatPanel from './ChatPanel';
|
||||
import { WindowIdContext } from '../NewWindowOrIFrame';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import '../../utils/window/eventHandlerOverrides';
|
||||
|
||||
jest.mock('@joplin/lib/services/ai/noteChat', () => ({ runNoteChat: jest.fn() }));
|
||||
jest.mock('./ChatMessageItem', () => ({ message }: { message: { text: string } }) => <div>{message.text}</div>);
|
||||
|
||||
const Panel = ChatPanel.WrappedComponent;
|
||||
const message: AiChatMessage = { id: 'question', createdTime: 1, role: 'user', text: 'Question', raw: [], noteId: 'note-a', noteTitle: 'A' };
|
||||
|
||||
const renderPanel = (props: Partial<React.ComponentProps<typeof Panel>> = {}) => render(
|
||||
<WindowIdContext.Provider value='second'><Panel
|
||||
themeId={1} available={true} unavailableHint='' providerType='joplin-cloud'
|
||||
noteId={null} noteTitle='' noteIsEncrypted={false} messages={[]} aiDegraded={false} dispatch={jest.fn()}
|
||||
showToolbarButton={false}
|
||||
{...props}
|
||||
/></WindowIdContext.Provider>,
|
||||
);
|
||||
|
||||
const openHistory = async (view: ReturnType<typeof render>) => {
|
||||
await act(async () => { fireEvent.click(view.getByTitle('Chat history')); });
|
||||
};
|
||||
|
||||
const saveConversation = async (id: string, title: string, updatedTime = 1) => {
|
||||
await ChatConversation.save({ id, title, created_time: 1, updated_time: updatedTime }, { isNew: true, autoTimestamp: false });
|
||||
};
|
||||
|
||||
const saveMessage = async (conversationId: string, text: string) => {
|
||||
await ChatConversation.addMessage(conversationId, { id: uuid.create(), createdTime: 1, role: 'user', text, raw: [], noteId: '', noteTitle: '' });
|
||||
};
|
||||
|
||||
describe('ChatPanel', () => {
|
||||
let modelDispatch: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
jest.mocked(runNoteChat).mockReset();
|
||||
modelDispatch = jest.fn();
|
||||
BaseModel.dispatch = modelDispatch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
BaseModel.dispatch = () => {};
|
||||
});
|
||||
|
||||
it('should search conversation titles and messages', async () => {
|
||||
await saveConversation('shopping', 'Shopping list');
|
||||
await saveConversation('travel', 'Travel plans');
|
||||
await saveMessage('travel', 'Visit Paris');
|
||||
const view = renderPanel();
|
||||
await openHistory(view);
|
||||
await view.findByText('Shopping list');
|
||||
const search = view.getByRole('searchbox', { name: 'Search conversations' });
|
||||
fireEvent.change(search, { target: { value: 'Shopping' } });
|
||||
await waitFor(() => expect(view.getAllByRole('listitem')).toHaveLength(1));
|
||||
expect(view.getByText('Shopping list')).toBeTruthy();
|
||||
fireEvent.change(search, { target: { value: 'Paris' } });
|
||||
expect(await view.findByText('Travel plans')).toBeTruthy();
|
||||
expect(view.getAllByRole('listitem')).toHaveLength(1);
|
||||
fireEvent.change(search, { target: { value: '' } });
|
||||
expect(await view.findByText('Shopping list')).toBeTruthy();
|
||||
expect(view.getAllByRole('listitem')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should rename a conversation inline when Enter is pressed', async () => {
|
||||
await saveConversation('chat-1', 'Old title');
|
||||
const view = renderPanel();
|
||||
await openHistory(view);
|
||||
const row = (await view.findByText('Old title')).closest('li');
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Rename' }));
|
||||
const input = within(row).getByRole('textbox', { name: 'Conversation title' });
|
||||
fireEvent.change(input, { target: { value: 'New title' } });
|
||||
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }); });
|
||||
expect(await view.findByText('New title')).toBeTruthy();
|
||||
expect(view.queryByRole('textbox', { name: 'Conversation title' })).toBeNull();
|
||||
expect(await ChatConversation.load('chat-1')).toMatchObject({ title: 'New title' });
|
||||
});
|
||||
|
||||
it('should close the history popup when clicking outside of it', async () => {
|
||||
await saveConversation('chat-1', 'Saved chat');
|
||||
const view = renderPanel();
|
||||
await openHistory(view);
|
||||
await view.findByText('Saved chat');
|
||||
fireEvent.mouseDown(view.getByRole('searchbox', { name: 'Search conversations' }));
|
||||
expect(view.getByText('Saved chat')).toBeTruthy();
|
||||
fireEvent.mouseDown(view.getByRole('textbox', { name: 'Chat message' }));
|
||||
await waitFor(() => expect(view.queryByText('Saved chat')).toBeNull());
|
||||
});
|
||||
|
||||
it('should delete the selected conversation and its messages', async () => {
|
||||
await saveConversation('delete-chat', 'Delete this chat');
|
||||
await saveConversation('keep-chat', 'Keep this chat');
|
||||
await saveMessage('delete-chat', 'Delete this message');
|
||||
await saveMessage('keep-chat', 'Keep this message');
|
||||
const dispatch = jest.fn();
|
||||
const view = renderPanel({ conversationId: 'delete-chat', dispatch });
|
||||
await openHistory(view);
|
||||
const row = (await view.findByText('Delete this chat')).closest('li');
|
||||
await act(async () => { fireEvent.click(within(row).getByRole('button', { name: 'Delete' })); });
|
||||
await waitFor(() => expect(view.queryByText('Delete this chat')).toBeNull());
|
||||
expect(view.getByText('Keep this chat')).toBeTruthy();
|
||||
expect((await ChatConversation.history()).map(conversation => conversation.id)).toEqual(['keep-chat']);
|
||||
expect(await ChatConversation.messages('keep-chat')).toMatchObject([{ text: 'Keep this message' }]);
|
||||
await saveConversation('delete-chat', 'Recreated chat');
|
||||
expect(await ChatConversation.messages('delete-chat')).toEqual([]);
|
||||
expect(dispatch).toHaveBeenCalledWith({ type: 'AI_CHAT_DELETE', conversationId: 'delete-chat' });
|
||||
});
|
||||
|
||||
it('should keep history when New chat opens an empty conversation in the current window', async () => {
|
||||
await saveConversation('chat-1', 'Current chat');
|
||||
const dispatch = jest.fn();
|
||||
const view = renderPanel({ conversationId: 'chat-1', messages: [message], dispatch });
|
||||
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'New chat' })); });
|
||||
await waitFor(() => expect(dispatch).toHaveBeenCalled());
|
||||
expect(await ChatConversation.load('chat-1')).toMatchObject({ title: 'Current chat' });
|
||||
const action = dispatch.mock.calls[0][0];
|
||||
expect(action).toEqual({ type: 'AI_CHAT_OPEN', windowId: 'second', conversationId: expect.any(String), messages: [] });
|
||||
expect(action.conversationId).not.toBe('chat-1');
|
||||
const state = createAppDefaultState({});
|
||||
state.aiChatConversationId = 'main-chat';
|
||||
state.aiChatMessages = [message];
|
||||
state.backgroundWindows = { second: { ...createAppDefaultWindowState(), windowId: 'second' } };
|
||||
const opened = appReducer(state, action);
|
||||
expect(opened.aiChatConversationId).toBe('main-chat');
|
||||
expect(opened.aiChatMessages).toEqual([message]);
|
||||
expect(opened.backgroundWindows.second.aiChatConversationId).toBe(action.conversationId);
|
||||
expect(opened.backgroundWindows.second.aiChatMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not add an empty conversation to history', async () => {
|
||||
await saveConversation('chat-1', 'Current chat');
|
||||
const before = await ChatConversation.history();
|
||||
const dispatch = jest.fn();
|
||||
const view = renderPanel({ conversationId: 'chat-1', messages: [message], dispatch });
|
||||
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'New chat' })); });
|
||||
await waitFor(() => expect(dispatch).toHaveBeenCalled());
|
||||
const after = await ChatConversation.history();
|
||||
expect(after).toHaveLength(before.length);
|
||||
});
|
||||
|
||||
it('should open the selected history row in the current window', async () => {
|
||||
await saveConversation('chat-1', 'Current chat');
|
||||
await saveConversation('chat-2', 'Saved chat');
|
||||
const execute = jest.spyOn(CommandService.instance(), 'executeInWindow').mockResolvedValue(undefined);
|
||||
const view = renderPanel({ conversationId: 'chat-1', messages: [message] });
|
||||
await openHistory(view);
|
||||
fireEvent.click(await view.findByRole('button', { name: /Saved chat/ }));
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledWith('openAiChatConversation', { windowId: 'second', args: ['chat-2'] }));
|
||||
});
|
||||
|
||||
it('should save the question and reply with the active note ID and title as they are created', async () => {
|
||||
jest.spyOn(Note, 'load').mockResolvedValue({ id: 'note-a', title: 'A', body: '' });
|
||||
jest.mocked(runNoteChat).mockImplementation(async (_context, _history, _text, _tools, onHistoryChanged) => {
|
||||
onHistoryChanged([{ role: ChatRole.System, content: '' }, { role: ChatRole.Assistant, content: 'Reply' }]);
|
||||
return [];
|
||||
});
|
||||
const view = renderPanel({ conversationId: 'chat-1', noteId: 'note-a', noteTitle: 'A' });
|
||||
fireEvent.change(view.getByRole('textbox'), { target: { value: 'Question' } });
|
||||
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
|
||||
const expected = [
|
||||
{ role: 'user', text: 'Question', noteId: 'note-a', noteTitle: 'A' },
|
||||
{ role: 'assistant', text: 'Reply', noteId: 'note-a', noteTitle: 'A' },
|
||||
];
|
||||
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject(expected));
|
||||
expect(await ChatConversation.load('chat-1')).toMatchObject({ title: 'Question' });
|
||||
const appended = modelDispatch.mock.calls.map(([action]) => action).filter(action => action.type === 'AI_CHAT_APPEND');
|
||||
expect(appended).toMatchObject(expected.map(message => ({ conversationId: 'chat-1', message })));
|
||||
});
|
||||
|
||||
it('should remove the saved question when the request fails before any reply', async () => {
|
||||
jest.spyOn(Note, 'load').mockResolvedValue({ id: 'note-a', title: 'A', body: '' });
|
||||
jest.mocked(runNoteChat).mockRejectedValue(new Error('Network down'));
|
||||
const view = renderPanel({ conversationId: 'chat-1', noteId: 'note-a', noteTitle: 'A', dispatch: jest.fn() });
|
||||
fireEvent.change(view.getByRole('textbox'), { target: { value: 'Question' } });
|
||||
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
|
||||
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject([{ role: 'error', text: 'Network down' }]));
|
||||
});
|
||||
|
||||
it('should assign an ID before messages are added to a new conversation', async () => {
|
||||
const dispatch = jest.fn();
|
||||
renderPanel({ dispatch });
|
||||
await waitFor(() => expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'AI_CHAT_OPEN', windowId: 'second', conversationId: expect.any(String), messages: [],
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import Logger from '@joplin/utils/Logger';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { AiChatMessage, AppState } from '../../app.reducer';
|
||||
import { runNoteChat } from '@joplin/lib/services/ai/noteChat';
|
||||
import { AvailabilityReason, chatAvailability } from '@joplin/lib/services/ai/availability';
|
||||
import { chatAvailability } from '@joplin/lib/services/ai/availability';
|
||||
import { WindowIdContext } from '../NewWindowOrIFrame';
|
||||
import AiDegradedNotice from '../AiDegradedNotice';
|
||||
import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/types';
|
||||
@@ -18,35 +18,26 @@ 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';
|
||||
import ChatConversation from '@joplin/lib/models/ChatConversation';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import dialogs from '../dialogs';
|
||||
import ChatHistory, { Conversation } from './ChatHistory';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
|
||||
const logger = Logger.create('ChatPanel');
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
available: boolean;
|
||||
availabilityReason?: AvailabilityReason;
|
||||
unavailableHint: string;
|
||||
providerType: string;
|
||||
noteId: string | null;
|
||||
noteTitle: string;
|
||||
noteIsEncrypted: boolean;
|
||||
messages: AiChatMessage[];
|
||||
conversationId?: string|null;
|
||||
aiDegraded: boolean;
|
||||
showToolbarButton: boolean;
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
const disclosureSetting = 'ai.chat.disclosureAcknowledged';
|
||||
|
||||
const makeId = () => uuid.create();
|
||||
let nextMessageId = 0;
|
||||
const makeId = () => `m-${Date.now()}-${++nextMessageId}`;
|
||||
|
||||
const waitForNextNoteChangeOrTimeout = (noteId: string, timeout: number) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
@@ -113,40 +104,7 @@ const useHasFocus = () => {
|
||||
const ChatPanel: React.FC<Props> = (props) => {
|
||||
const { dispatch, messages } = props;
|
||||
const [input, setInput] = useState('');
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const historyId = useId();
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const loadConversationHistory = useCallback(async (search = '') => {
|
||||
try {
|
||||
setConversations(await ChatConversation.history(search));
|
||||
} catch (error) {
|
||||
logger.error('Could not load chat conversations', error);
|
||||
}
|
||||
}, []);
|
||||
const handleHistoryToggle = useCallback(async () => {
|
||||
setHistoryOpen(!historyOpen);
|
||||
if (historyOpen) return;
|
||||
await loadConversationHistory();
|
||||
}, [historyOpen, loadConversationHistory]);
|
||||
const historyPopupRef = useRef<HTMLDivElement>(null);
|
||||
const historyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const closeHistory = useCallback(() => {
|
||||
setHistoryOpen(false);
|
||||
focus('ChatPanel::closeHistory', historyButtonRef.current);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!historyOpen) return () => {};
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (historyPopupRef.current?.contains(target) || historyButtonRef.current?.contains(target)) return;
|
||||
setHistoryOpen(false);
|
||||
};
|
||||
const doc = historyButtonRef.current.ownerDocument;
|
||||
doc.addEventListener('mousedown', onMouseDown);
|
||||
return () => doc.removeEventListener('mousedown', onMouseDown);
|
||||
}, [historyOpen]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const archivingRef = useRef(false);
|
||||
const [disclosureShown, setDisclosureShown] = useState<boolean>(() => {
|
||||
try {
|
||||
return !!Setting.value(disclosureSetting);
|
||||
@@ -169,35 +127,13 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
|
||||
const windowId = useContext(WindowIdContext);
|
||||
|
||||
const appendMessage = useCallback(async (message: Omit<AiChatMessage, 'noteId' | 'noteTitle' | 'createdTime'>) => {
|
||||
const fullMessage: AiChatMessage = { ...message, createdTime: Date.now(), noteId: props.noteId ?? '', noteTitle: props.noteId ? props.noteTitle : '' };
|
||||
try {
|
||||
await ChatConversation.addMessage(props.conversationId, fullMessage);
|
||||
} catch (error) {
|
||||
logger.error('Could not save chat message:', error);
|
||||
}
|
||||
}, [props.noteId, props.noteTitle, props.conversationId]);
|
||||
const appendMessage = useCallback((message: AiChatMessage) => {
|
||||
dispatch({ type: 'AI_CHAT_APPEND', windowId, message });
|
||||
}, [dispatch, windowId]);
|
||||
|
||||
const removeMessage = useCallback(async (id: string) => {
|
||||
try {
|
||||
await ChatConversation.removeMessage(props.conversationId, id);
|
||||
} catch (error) {
|
||||
logger.error('Could not remove chat message:', error);
|
||||
}
|
||||
}, [props.conversationId]);
|
||||
|
||||
const addToolResult = useCallback(async (result: ChatToolMessage) => {
|
||||
try {
|
||||
await ChatConversation.addToolResult(props.conversationId, result);
|
||||
} catch (error) {
|
||||
logger.error('Could not save chat tool result:', error);
|
||||
}
|
||||
}, [props.conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.conversationId) return;
|
||||
dispatch({ type: 'AI_CHAT_OPEN', windowId, conversationId: uuid.create(), messages });
|
||||
}, [props.conversationId, dispatch, windowId, messages]);
|
||||
const addToolResult = useCallback((result: ChatToolMessage) => {
|
||||
dispatch({ type: 'AI_CHAT_ADD_TOOL_RESULT', windowId, toolCall: result });
|
||||
}, [dispatch, windowId]);
|
||||
|
||||
// Drop a separator when the active note changes mid-conversation. Skip
|
||||
// the first ever opened note (no prior context to separate from).
|
||||
@@ -206,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)'));
|
||||
void appendMessage({
|
||||
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]);
|
||||
|
||||
@@ -233,14 +167,14 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
cancelRequest();
|
||||
void appendMessage({ id: makeId(), role: 'assistant', text: _('(Cancelled)'), raw: [] });
|
||||
appendMessage({ id: makeId(), role: 'assistant', text: _('(Cancelled)'), raw: [] });
|
||||
}, [cancelRequest, appendMessage]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
if (!props.noteId) {
|
||||
void appendMessage({
|
||||
appendMessage({
|
||||
id: makeId(), role: 'error', text: _('Open a note to start chatting.'), raw: [],
|
||||
});
|
||||
return;
|
||||
@@ -254,8 +188,8 @@ 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;
|
||||
void appendMessage({
|
||||
let hadSuccessfulResponse = false;
|
||||
appendMessage({
|
||||
id: userTurnId,
|
||||
role: 'user',
|
||||
text,
|
||||
@@ -264,7 +198,6 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
|
||||
try {
|
||||
const note = await Note.load(props.noteId);
|
||||
if (abortController.signal.aborted) return;
|
||||
if (!note) throw new Error(`Note not found: ${props.noteId}`);
|
||||
|
||||
const getContext = async () => {
|
||||
@@ -284,8 +217,6 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
body: note.body,
|
||||
title: note.title,
|
||||
selection,
|
||||
noteId: note.id,
|
||||
folderId: note.parent_id,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -305,16 +236,15 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
// System messages are not shown in the UI
|
||||
if (entry.role === ChatRole.System) continue;
|
||||
|
||||
if (entry.role === ChatRole.Tool) {
|
||||
void addToolResult(entry);
|
||||
} else {
|
||||
hadVisibleResponse ||= !entry.hide;
|
||||
hadSuccessfulResponse = true;
|
||||
|
||||
void appendMessage({
|
||||
if (entry.role === ChatRole.Tool) {
|
||||
addToolResult(entry);
|
||||
} else {
|
||||
appendMessage({
|
||||
id: makeId(),
|
||||
role: entry.role,
|
||||
text: entry.content,
|
||||
hide: entry.hide,
|
||||
raw: [entry],
|
||||
});
|
||||
}
|
||||
@@ -357,7 +287,7 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
await changeListener;
|
||||
},
|
||||
displayError: (message) => {
|
||||
void appendMessage({ id: makeId(), role: 'error', text: message, raw: [] });
|
||||
appendMessage({ id: makeId(), role: 'error', text: message, raw: [] });
|
||||
},
|
||||
}, onHistoryChanged, abortController.signal,
|
||||
);
|
||||
@@ -365,78 +295,27 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
logger.warn('Chat failed:', error);
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
if (!hadVisibleResponse) {
|
||||
void removeMessage(userTurnId);
|
||||
if (!hadSuccessfulResponse) {
|
||||
dispatch({ type: 'AI_CHAT_REMOVE', windowId, id: userTurnId });
|
||||
setInput(text);
|
||||
}
|
||||
void appendMessage({ id: makeId(), role: 'error', text: error.message || _('Something went wrong.'), raw: [] });
|
||||
appendMessage({ id: makeId(), role: 'error', text: error.message || _('Something went wrong.'), raw: [] });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [input, sending, props.noteId, conversationTurns, windowId, addToolResult, appendMessage, removeMessage, cancelRequest, abortControllerRef]);
|
||||
}, [input, sending, props.noteId, conversationTurns, windowId, addToolResult, appendMessage, dispatch, cancelRequest, abortControllerRef]);
|
||||
|
||||
const handleAcknowledgeDisclosure = useCallback(() => {
|
||||
Setting.setValue(disclosureSetting, true);
|
||||
setDisclosureShown(true);
|
||||
}, []);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
if (archivingRef.current) return;
|
||||
const handleReset = useCallback(() => {
|
||||
cancelRequest();
|
||||
// Only saved once a message is sent so empty chats don't show up in history
|
||||
dispatch({ type: 'AI_CHAT_OPEN', windowId, conversationId: uuid.create(), messages: [] });
|
||||
|
||||
dispatch({ type: 'AI_CHAT_RESET', windowId: windowId });
|
||||
}, [dispatch, windowId, cancelRequest]);
|
||||
|
||||
const handleOpenConversation = useCallback(async (conversationId: string) => {
|
||||
if (conversationId === props.conversationId || archivingRef.current) return;
|
||||
archivingRef.current = true;
|
||||
cancelRequest();
|
||||
try {
|
||||
await CommandService.instance().executeInWindow('openAiChatConversation', { windowId, args: [conversationId] });
|
||||
} catch (error) {
|
||||
logger.error('Could not open chat conversation:', error);
|
||||
} finally {
|
||||
archivingRef.current = false;
|
||||
}
|
||||
}, [windowId, cancelRequest, props.conversationId]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void CommandService.instance().executeInWindow('toggleAiChat', { windowId: windowId, args: [] });
|
||||
}, [windowId]);
|
||||
|
||||
const handleDeleteConversation = useCallback(async (conversationId: string) => {
|
||||
if (archivingRef.current) return;
|
||||
archivingRef.current = true;
|
||||
if (conversationId === props.conversationId) cancelRequest();
|
||||
try {
|
||||
await ChatConversation.deleteConversation(conversationId);
|
||||
setConversations(current => current.filter(item => item.id !== conversationId));
|
||||
dispatch({ type: 'AI_CHAT_DELETE', conversationId });
|
||||
} catch (error) {
|
||||
logger.error('Could not delete chat conversation:', error);
|
||||
await dialogs.alert(_('Could not delete conversation.'));
|
||||
} finally {
|
||||
archivingRef.current = false;
|
||||
}
|
||||
}, [dispatch, cancelRequest, props.conversationId]);
|
||||
|
||||
const handleRenameConversation = useCallback(async (conversation: Conversation, newTitle: string) => {
|
||||
const title = newTitle.trim();
|
||||
if (!title || title === conversation.title) return;
|
||||
try {
|
||||
await ChatConversation.renameConversation(conversation.id, title);
|
||||
setConversations(current => current.map(item => item.id === conversation.id ? { ...item, title } : item));
|
||||
} catch (error) {
|
||||
logger.error('Could not rename chat conversation:', error);
|
||||
await dialogs.alert(_('Could not rename conversation.'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleHideToolbarButton = useCallback(() => {
|
||||
Setting.setValue('ai.chat.showToolbarButton', false);
|
||||
handleClose();
|
||||
}, [handleClose]);
|
||||
|
||||
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.
|
||||
@@ -450,16 +329,7 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
if (!props.available) {
|
||||
const content = (
|
||||
<div className='disabled-message'>
|
||||
<div className='hint'>{props.unavailableHint}</div>
|
||||
{props.showToolbarButton && <>
|
||||
<div className='hint'>{_('If you do not want to use the AI features, you may hide the toolbar button:')}</div>
|
||||
<Button
|
||||
level={ButtonLevel.Secondary}
|
||||
title={_('Hide the AI Chat button')}
|
||||
onClick={handleHideToolbarButton}
|
||||
/>
|
||||
<div className='hint'>{_('(You can show it again from Settings → AI)')}</div>
|
||||
</>}
|
||||
{props.unavailableHint}
|
||||
</div>
|
||||
);
|
||||
return { content, showingMessages: false };
|
||||
@@ -473,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'>
|
||||
@@ -524,12 +385,10 @@ 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 closeLabel = _('Close');
|
||||
const newChatLabel = _('New chat');
|
||||
const headerId = useId();
|
||||
const { content, showingMessages } = renderContent();
|
||||
|
||||
@@ -541,46 +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>
|
||||
{props.availabilityReason !== AvailabilityReason.Disabled && (
|
||||
<button
|
||||
type='button'
|
||||
className='history toolbar-button'
|
||||
ref={historyButtonRef}
|
||||
onClick={handleHistoryToggle}
|
||||
title={_('Chat history')}
|
||||
aria-label={_('Chat history')}
|
||||
aria-expanded={historyOpen}
|
||||
aria-controls={historyId}
|
||||
>
|
||||
<i className='toolbar-icon far fa-clock' aria-hidden='true'/>
|
||||
</button>
|
||||
{showingMessages && (
|
||||
<button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>
|
||||
)}
|
||||
{historyOpen && (
|
||||
// Placed right after its button so that Tab moves from the button into the popup
|
||||
<ChatHistory
|
||||
conversations={conversations}
|
||||
currentConversationId={props.conversationId}
|
||||
id={historyId}
|
||||
popupRef={historyPopupRef}
|
||||
onSearchChange={loadConversationHistory}
|
||||
onOpen={handleOpenConversation}
|
||||
onRename={handleRenameConversation}
|
||||
onDelete={handleDeleteConversation}
|
||||
onClose={closeHistory}
|
||||
/>
|
||||
)}
|
||||
<button type='button' className='reset toolbar-button' onClick={handleNewChat} disabled={!showingMessages} title={newChatLabel} aria-label={newChatLabel}>
|
||||
<i className='toolbar-icon fas fa-comment-medical' aria-hidden='true'/>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='close toolbar-button'
|
||||
onClick={handleClose}
|
||||
title={closeLabel}
|
||||
aria-label={closeLabel}
|
||||
><i className='toolbar-icon fas fa-times' role='img' aria-hidden={true}/></button>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
@@ -599,19 +423,14 @@ const mapStateToProps = (state: AppState, ownProps: OwnProps) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
available: availability.available,
|
||||
availabilityReason: availability.reason,
|
||||
unavailableHint: availability.hint ?? '',
|
||||
providerType: state.settings['ai.chat.providerType'] || 'openai-compatible',
|
||||
noteId,
|
||||
noteTitle: note?.title || '',
|
||||
noteIsEncrypted: !!note?.encryption_applied,
|
||||
messages: windowState.aiChatMessages || [],
|
||||
conversationId: windowState.aiChatConversationId,
|
||||
aiDegraded: !!state.aiStatus?.degraded,
|
||||
showToolbarButton: !!state.settings['ai.chat.showToolbarButton'],
|
||||
};
|
||||
};
|
||||
|
||||
const ConversationPanel: React.FC<Props> = props => <ChatPanel key={props.conversationId} {...props} />;
|
||||
|
||||
export default connect(mapStateToProps)(ConversationPanel);
|
||||
export default connect(mapStateToProps)(ChatPanel);
|
||||
@@ -1,11 +1,8 @@
|
||||
@use './ChatHistory.scss';
|
||||
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--joplin-background-color);
|
||||
color: var(--joplin-color);
|
||||
@@ -14,7 +11,6 @@
|
||||
}
|
||||
|
||||
.chat-panel > .header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -23,24 +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, > .history {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--joplin-color-faded);
|
||||
cursor: pointer;
|
||||
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:not(:disabled) {
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
}
|
||||
.chat-panel > .header > .reset:hover {
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
|
||||
.chat-panel > .degraded-status {
|
||||
@@ -218,10 +213,10 @@
|
||||
.chat-panel > .disabled-message {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--joplin-color-faded);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import ButtonBar from './ButtonBar';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../services/bridge';
|
||||
import Setting, { AppType, SettingMetadataSection, SettingsRecord, SettingValueType } from '@joplin/lib/models/Setting';
|
||||
import Setting, { AppType, SettingMetadataSection, SettingValueType, SyncStartupOperation } from '@joplin/lib/models/Setting';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen';
|
||||
import NoteLockSettings from './controls/NoteLockSettings';
|
||||
@@ -25,12 +25,10 @@ import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
|
||||
import AiIndexStatus from './controls/AiIndexStatus';
|
||||
import AiStatus from './controls/AiStatus';
|
||||
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
|
||||
import SettingComponent from './controls/SettingComponent';
|
||||
import SettingComponent, { UpdateSettingValueEvent } from './controls/SettingComponent';
|
||||
import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
import { OnChangeEvent } from '../lib/SearchInput/SearchInput';
|
||||
import highlightSearchText from './searchHighlight';
|
||||
import { UpdateSettingValueEvent } from './types';
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
|
||||
interface Font {
|
||||
@@ -44,21 +42,8 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
defaultSection?: string;
|
||||
settings: SettingsRecord;
|
||||
themeId: number;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
interface State extends shared.ConfigScreenState {
|
||||
selectedSectionName: string;
|
||||
screenName: string;
|
||||
needRestart: boolean;
|
||||
fonts: string[];
|
||||
}
|
||||
|
||||
class ConfigScreenComponent extends React.Component<Props, State> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old class component without props/state interfaces; tightening requires structural refactor
|
||||
class ConfigScreenComponent extends React.Component<any, any> {
|
||||
|
||||
private rowStyle_: React.CSSProperties = null;
|
||||
|
||||
@@ -117,7 +102,7 @@ class ConfigScreenComponent extends React.Component<Props, State> {
|
||||
});
|
||||
}
|
||||
}
|
||||
return await shared.checkSyncConfig(this, this.state.settings);
|
||||
await shared.checkSyncConfig(this, this.state.settings);
|
||||
}
|
||||
|
||||
public UNSAFE_componentWillMount() {
|
||||
@@ -137,7 +122,35 @@ class ConfigScreenComponent extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
private async handleSettingButton(key: string) {
|
||||
await shared.onSettingButtonPress(this, Setting.settingMetadata(key));
|
||||
if (key === 'sync.clearLocalSyncStateButton') {
|
||||
if (!await shim.showConfirmationDialog('This cannot be undone. Do you want to continue?')) return;
|
||||
Setting.setValue('sync.startupOperation', SyncStartupOperation.ClearLocalSyncState);
|
||||
await Setting.saveAll();
|
||||
await restart();
|
||||
} else if (key === 'sync.clearLocalDataButton') {
|
||||
if (!await shim.showConfirmationDialog('This cannot be undone. Do you want to continue?')) return;
|
||||
Setting.setValue('sync.startupOperation', SyncStartupOperation.ClearLocalData);
|
||||
await Setting.saveAll();
|
||||
await restart();
|
||||
} else if (key === 'ocr.clearLanguageDataCacheButton') {
|
||||
if (!await shim.showConfirmationDialog(this.restartMessage())) return;
|
||||
Setting.setValue('ocr.clearLanguageDataCache', true);
|
||||
await restart();
|
||||
} else if (key === 'ai.usage.resetButton') {
|
||||
if (!await shim.showConfirmationDialog(_('Reset AI token usage counters?'))) return;
|
||||
Setting.setValue('ai.usage.inputTokens', 0);
|
||||
Setting.setValue('ai.usage.outputTokens', 0);
|
||||
await Setting.saveAll();
|
||||
} else if (key === 'ai.chat.testButton') {
|
||||
await shared.checkAiConfig(this);
|
||||
} else if (key === 'sync.openSyncWizard') {
|
||||
this.props.dispatch({
|
||||
type: 'DIALOG_OPEN',
|
||||
name: 'syncWizard',
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unhandled key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
public sectionByName(name: string) {
|
||||
@@ -209,7 +222,7 @@ class ConfigScreenComponent extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
public sectionToComponent(key: string, section: SettingMetadataSection, settings: shared.SettingsMap, selected: boolean) {
|
||||
public sectionToComponent(key: string, section: SettingMetadataSection, settings: Record<string, unknown>, selected: boolean) {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const searchMode = !!normalizeQuery(this.state.searchQuery);
|
||||
|
||||
@@ -295,6 +308,24 @@ class ConfigScreenComponent extends React.Component<Props, State> {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinCloud')) {
|
||||
const goToJoplinCloudLogin = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'JoplinCloudLogin',
|
||||
});
|
||||
};
|
||||
settingComps.push(
|
||||
<div key="connect_to_joplin_cloud_button" style={this.rowStyle_}>
|
||||
<Button
|
||||
title={_('Connect to Joplin Cloud')}
|
||||
level={ButtonLevel.Primary}
|
||||
onClick={goToJoplinCloudLogin}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml')) {
|
||||
const server = settings['sync.11.path'] as string;
|
||||
|
||||
@@ -366,7 +397,7 @@ class ConfigScreenComponent extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
private onUpdateSettingValue = ({ key, value }: UpdateSettingValueEvent<string>) => {
|
||||
private onUpdateSettingValue = ({ key, value }: UpdateSettingValueEvent) => {
|
||||
const md = Setting.settingMetadata(key);
|
||||
if (md.needRestart) {
|
||||
this.setState({ needRestart: true });
|
||||
@@ -374,10 +405,6 @@ class ConfigScreenComponent extends React.Component<Props, State> {
|
||||
shared.updateSettingValue(this, key, value);
|
||||
};
|
||||
|
||||
public setSettingValue<Key extends string>(key: Key, value: SettingValueType<Key>) {
|
||||
this.onUpdateSettingValue({ key: key as string, value });
|
||||
}
|
||||
|
||||
private renderSearchHighlightedText = (text: string): React.ReactNode => {
|
||||
return highlightSearchText(text, this.state.searchQuery);
|
||||
};
|
||||
@@ -398,7 +425,7 @@ class ConfigScreenComponent extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
private restartMessage() {
|
||||
return shared.restartMessage();
|
||||
return _('The application must be restarted for these changes to take effect.');
|
||||
}
|
||||
|
||||
private async restartApp() {
|
||||
@@ -444,7 +471,7 @@ class ConfigScreenComponent extends React.Component<Props, State> {
|
||||
const searchMode = !!searchQuery;
|
||||
const sectionFilter = this.state.searchSectionFilter;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
const style = {
|
||||
...this.props.style,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import Setting, { SettingButtonStyle } from '@joplin/lib/models/Setting';
|
||||
import Button, { ButtonLevel } from '../../Button/Button';
|
||||
import { useCallback, useState } from 'react';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('SettingButton');
|
||||
|
||||
interface Props {
|
||||
settingKey: string;
|
||||
onSettingButtonClick: (key: string)=> Promise<void>;
|
||||
}
|
||||
|
||||
const SettingButton: React.FC<Props> = ({
|
||||
settingKey, onSettingButtonClick,
|
||||
}) => {
|
||||
const key = settingKey;
|
||||
const md = Setting.settingMetadata(key);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string|null>(null);
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await onSettingButtonClick(key);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to run command for button', key, error);
|
||||
setError(String(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [key, onSettingButtonClick]);
|
||||
|
||||
return <>
|
||||
<Button
|
||||
level={md.buttonStyle === SettingButtonStyle.Highlighted ? ButtonLevel.Primary : ButtonLevel.Secondary}
|
||||
title={md.label()}
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
/>
|
||||
{error && <span className='error'>{error}</span>}
|
||||
</>;
|
||||
};
|
||||
|
||||
export default SettingButton;
|
||||
@@ -11,8 +11,6 @@ import FontSearch from './FontSearch';
|
||||
import * as pathUtils from '@joplin/lib/path-utils';
|
||||
import SettingLabel from './SettingLabel';
|
||||
import SettingDescription from './SettingDescription';
|
||||
import { OnUpdateSettingValue } from '../types';
|
||||
import SettingButton from './SettingButton';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each control component has different prop types
|
||||
const settingKeyToControl: Record<string, React.FC<any>> = {
|
||||
@@ -20,13 +18,18 @@ const settingKeyToControl: Record<string, React.FC<any>> = {
|
||||
'globalHotkey': control_GlobalHotkeyInput,
|
||||
};
|
||||
|
||||
export interface UpdateSettingValueEvent {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
settingKey: string;
|
||||
value: unknown;
|
||||
fonts: string[];
|
||||
onUpdateSettingValue: OnUpdateSettingValue;
|
||||
onSettingButtonClick: (key: string)=> Promise<void>;
|
||||
onUpdateSettingValue: (event: UpdateSettingValueEvent)=> void;
|
||||
onSettingButtonClick: (key: string)=> void;
|
||||
renderSearchText?: (text: string)=> React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -360,9 +363,10 @@ const SettingComponent: React.FC<Props> = props => {
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
{labelComp}
|
||||
<SettingButton
|
||||
settingKey={key}
|
||||
onSettingButtonClick={props.onSettingButtonClick}
|
||||
<Button
|
||||
level={ButtonLevel.Secondary}
|
||||
title={md.label()}
|
||||
onClick={md.onClick ? md.onClick : () => props.onSettingButtonClick(key)}
|
||||
/>
|
||||
{descriptionComp}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { SettingsRecord } from '@joplin/lib/models/Setting';
|
||||
|
||||
export interface UpdateSettingValueEvent<Key extends string> {
|
||||
key: Key;
|
||||
value: SettingsRecord[Key];
|
||||
}
|
||||
export type OnUpdateSettingValue = <Key extends string> (event: UpdateSettingValueEvent<Key>)=> void;
|
||||
@@ -5,10 +5,10 @@ import { _ } from '@joplin/lib/locale';
|
||||
import time from '@joplin/lib/time';
|
||||
import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
import dialogs from '../dialogs';
|
||||
import { decryptedStatText, determineKeyPassword, dontReencryptData, enableEncryptionConfirmationMessages, onSavePasswordClick, onToggleEnabledClick, reencryptData, upgradeMasterKey, useHasUnknownEncryptionMethodKeysMessage, useInputPasswords, useNeedMasterPassword, usePasswordChecker, useStats, useToggleShowDisabledMasterKeys } from '@joplin/lib/components/EncryptionConfigScreen/utils';
|
||||
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 { getDefaultMasterKey, getMasterPasswordStatusMessage, isKnownEncryptionMethod, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
|
||||
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';
|
||||
import { connect } from 'react-redux';
|
||||
@@ -76,7 +76,6 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
const { passwordChecks, masterPasswordKeys, masterPasswordStatus } = usePasswordChecker(props.masterKeys, props.activeMasterKeyId, props.masterPassword, props.passwords);
|
||||
const { showDisabledMasterKeys, toggleShowDisabledMasterKeys } = useToggleShowDisabledMasterKeys();
|
||||
const needMasterPassword = useNeedMasterPassword(passwordChecks, props.masterKeys);
|
||||
const unknownEncryptionMethodMessage = useHasUnknownEncryptionMethodKeysMessage(props.masterKeys);
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = wasMasterPasswordDialogOpen.current;
|
||||
@@ -174,7 +173,6 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
const password = inputPasswords[mk.id] ? inputPasswords[mk.id] : '';
|
||||
const isActive = props.activeMasterKeyId === mk.id;
|
||||
const activeIcon = isActive ? '✔' : '';
|
||||
const knownKeyType = isKnownEncryptionMethod(mk.encryption_method);
|
||||
const passwordOk = passwordChecks[mk.id] === true ? '✔' : '❌';
|
||||
|
||||
const renderPasswordInput = (masterKeyId: string) => {
|
||||
@@ -209,9 +207,7 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
<td style={theme.textStyle}>{mk.id}<br/>{_('Source: ')}{mk.source_application}</td>
|
||||
<td style={theme.textStyle}>{_('Created: ')}{time.formatMsToLocal(mk.created_time)}<br/>{_('Updated: ')}{time.formatMsToLocal(mk.updated_time)}</td>
|
||||
{renderPasswordInput(mk.id)}
|
||||
<td
|
||||
style={theme.textStyle}
|
||||
>{knownKeyType ? passwordOk : <span title={_('Unknown key format')}>❓</span>}</td>
|
||||
<td style={theme.textStyle}>{passwordOk}</td>
|
||||
<td style={theme.textStyle}>
|
||||
<button style={theme.buttonStyle} onClick={() => onToggleEnabledClick(mk)}>{masterKeyEnabled(mk) ? _('Disable') : _('Enable')}</button>
|
||||
</td>
|
||||
@@ -460,7 +456,7 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
const buttonTitle = CommandService.instance().label('openMasterPasswordDialog');
|
||||
|
||||
const needPasswordMessage = !needMasterPassword ? null : (
|
||||
<p className="warning">
|
||||
<p className="needpassword">
|
||||
{_('Your password is needed to decrypt some of your data.')}
|
||||
<br/>
|
||||
{_('Please click on "%s" to proceed, or set the passwords in the "%s" list below.', buttonTitle, _('Encryption keys'))}
|
||||
@@ -472,19 +468,12 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
</p>
|
||||
);
|
||||
|
||||
const needUpgradeAppMessage = !!unknownEncryptionMethodMessage && (
|
||||
<p className='warning'>
|
||||
{unknownEncryptionMethodMessage}
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="manage-password-section">
|
||||
<h2>{_('Master password')}</h2>
|
||||
<p className="status"><span>{_('Master password:')}</span> <span className="bold">{getMasterPasswordStatusMessage(masterPasswordStatus)}</span></p>
|
||||
{needPasswordMessage}
|
||||
{needUpgradeAppMessage}
|
||||
<Button className="managebutton" level={needMasterPassword ? ButtonLevel.Primary : ButtonLevel.Secondary} onClick={onManageMasterPassword} title={buttonTitle} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -617,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,
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.manage-password-section > .warning {
|
||||
.manage-password-section > .needpassword {
|
||||
color: var(--joplin-color-warn);
|
||||
}
|
||||
@@ -161,7 +161,7 @@ export default class ErrorBoundary extends React.Component<Props, State> {
|
||||
const isLegacyEditorError = !!this.state.error.stack.includes('CodeMirror/v5');
|
||||
|
||||
return (
|
||||
<div className='error-boundary'>
|
||||
<div style={{ overflow: 'auto', fontFamily: 'sans-serif', padding: '5px 20px' }}>
|
||||
<SwitchToNewEditorBanner isVisible={isLegacyEditorError} />
|
||||
<h1>Error</h1>
|
||||
{this.renderMessage()}
|
||||
|
||||
@@ -14,31 +14,39 @@ import { _ } from '@joplin/lib/locale';
|
||||
import { AppState } from '../app.reducer';
|
||||
import { saveLayout, loadLayout } from './ResizableLayout/utils/persist';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
|
||||
import { produce } from 'immer';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import bridge from '../services/bridge';
|
||||
import styled from 'styled-components';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { themeStyle, ThemeStyle } from '@joplin/lib/theme';
|
||||
import validateLayout from './ResizableLayout/utils/validateLayout';
|
||||
import iterateItems from './ResizableLayout/utils/iterateItems';
|
||||
import removeItem from './ResizableLayout/utils/removeItem';
|
||||
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
|
||||
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
|
||||
import removeKeylessItems from './ResizableLayout/utils/removeKeylessItems';
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import executeCallbackUrl from './MainScreen/handleCallbackUrl';
|
||||
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
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';
|
||||
import invitationRespond from '@joplin/lib/services/share/invitationRespond';
|
||||
import restart from '../services/restart';
|
||||
import { connect } from 'react-redux';
|
||||
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';
|
||||
|
||||
const logger = Logger.create('MainScreen');
|
||||
|
||||
import { ipcRenderer } from 'electron';
|
||||
import layoutKeyToLabel from '../utils/layout/layoutKeyToLabel';
|
||||
import MainLayoutPane from './MainLayoutPane';
|
||||
import WarningBanner from './WarningBanner';
|
||||
|
||||
interface Props {
|
||||
plugins: PluginStates;
|
||||
@@ -49,13 +57,26 @@ interface Props {
|
||||
style: React.CSSProperties & { width?: number; height?: number };
|
||||
layoutMoveMode: boolean;
|
||||
shouldUpgradeSyncTarget: boolean;
|
||||
hasDisabledSyncItems: boolean;
|
||||
hasDisabledEncryptionItems: boolean;
|
||||
hasMissingSyncCredentials: boolean;
|
||||
showMissingMasterKeyMessage: boolean;
|
||||
showNeedUpgradingMasterKeyMessage: boolean;
|
||||
showShouldReencryptMessage: boolean;
|
||||
themeId: number;
|
||||
shareInvitations: ShareInvitation[];
|
||||
isSafeMode: boolean;
|
||||
enableLegacyMarkdownEditor: boolean;
|
||||
needApiAuth: boolean;
|
||||
processingShareInvitationResponse: boolean;
|
||||
isResettingLayout: boolean;
|
||||
lastDeletion: StateLastDeletion;
|
||||
lastDeletionNotificationTime: number;
|
||||
mustUpgradeAppMessage: string;
|
||||
syncTargetAppMinVersion: string;
|
||||
showInvalidJoplinCloudCredential: boolean;
|
||||
toast: Toast;
|
||||
shouldSwitchToAppleSiliconVersion: boolean;
|
||||
}
|
||||
|
||||
interface ShareFolderDialogOptions {
|
||||
@@ -69,7 +90,8 @@ interface State {
|
||||
noteContentPropertiesDialogOptions: Record<string, unknown>;
|
||||
shareNoteDialogOptions: Record<string, unknown>;
|
||||
shareFolderDialogOptions: ShareFolderDialogOptions;
|
||||
messageBoxVisible: boolean;
|
||||
syncTargetAppMinVersionIsRelease: boolean | null;
|
||||
didSyncTargetAppMinVersionReleaseLoadFail: boolean;
|
||||
}
|
||||
|
||||
const StyledUserWebviewDialogContainer = styled.div`
|
||||
@@ -112,7 +134,8 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
visible: false,
|
||||
folderId: '',
|
||||
},
|
||||
messageBoxVisible: false,
|
||||
syncTargetAppMinVersionIsRelease: null,
|
||||
didSyncTargetAppMinVersionReleaseLoadFail: false,
|
||||
};
|
||||
|
||||
this.updateMainLayout(this.buildLayout(props.plugins));
|
||||
@@ -130,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) {
|
||||
@@ -162,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,
|
||||
},
|
||||
@@ -189,6 +208,11 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
return newLayout !== layout ? validateLayout(newLayout) : layout;
|
||||
}
|
||||
|
||||
private showShareInvitationNotification(props: Props): boolean {
|
||||
if (props.processingShareInvitationResponse) return false;
|
||||
return !!props.shareInvitations.find(i => i.status === 0);
|
||||
}
|
||||
|
||||
private buildLayout(plugins: PluginStates): LayoutItem {
|
||||
const rootLayoutSize = this.rootLayoutSize();
|
||||
|
||||
@@ -299,7 +323,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
public componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (prevProps.style.width !== this.props.style.width ||
|
||||
prevProps.style.height !== this.props.style.height ||
|
||||
prevState.messageBoxVisible !== this.state.messageBoxVisible
|
||||
this.messageBoxVisible(prevProps) !== this.messageBoxVisible(this.props)
|
||||
) {
|
||||
this.updateRootLayoutSize();
|
||||
}
|
||||
@@ -329,6 +353,13 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
value: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.props.mustUpgradeAppMessage !== prevProps.mustUpgradeAppMessage ||
|
||||
this.props.syncTargetAppMinVersion !== prevProps.syncTargetAppMinVersion
|
||||
) {
|
||||
void this.loadSyncTargetAppMinVersionIsRelease();
|
||||
}
|
||||
}
|
||||
|
||||
public layoutModeListenerKeyDown(event: KeyboardEvent) {
|
||||
@@ -339,6 +370,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
||||
public componentDidMount() {
|
||||
window.addEventListener('keydown', this.layoutModeListenerKeyDown);
|
||||
void this.loadSyncTargetAppMinVersionIsRelease();
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
@@ -346,6 +378,28 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
window.removeEventListener('keydown', this.layoutModeListenerKeyDown);
|
||||
}
|
||||
|
||||
private async loadSyncTargetAppMinVersionIsRelease() {
|
||||
const version = this.props.syncTargetAppMinVersion;
|
||||
|
||||
this.setState({
|
||||
syncTargetAppMinVersionIsRelease: null,
|
||||
didSyncTargetAppMinVersionReleaseLoadFail: false,
|
||||
});
|
||||
if (!this.props.mustUpgradeAppMessage || !version) return;
|
||||
|
||||
try {
|
||||
const syncTargetAppMinVersionIsRelease = await isReleaseVersion(version);
|
||||
if (!this.props.mustUpgradeAppMessage || this.props.syncTargetAppMinVersion !== version) return;
|
||||
this.setState({
|
||||
syncTargetAppMinVersionIsRelease,
|
||||
didSyncTargetAppMinVersionReleaseLoadFail: syncTargetAppMinVersionIsRelease === null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
if (!this.props.mustUpgradeAppMessage || this.props.syncTargetAppMinVersion !== version) return;
|
||||
this.setState({ didSyncTargetAppMinVersionReleaseLoadFail: true });
|
||||
}
|
||||
}
|
||||
|
||||
public rootLayoutSize() {
|
||||
return {
|
||||
@@ -356,7 +410,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
||||
public rowHeight() {
|
||||
if (!this.props) return 0;
|
||||
return this.props.style.height - (this.state.messageBoxVisible ? this.messageBoxHeight() : 0);
|
||||
return this.props.style.height - (this.messageBoxVisible() ? this.messageBoxHeight() : 0);
|
||||
}
|
||||
|
||||
public messageBoxHeight() {
|
||||
@@ -367,6 +421,8 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
const styleKey = [themeId, width, height, messageBoxVisible].join('_');
|
||||
if (styleKey === this.styleKey_) return this.styles_;
|
||||
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
this.styleKey_ = styleKey;
|
||||
|
||||
this.styles_ = {};
|
||||
@@ -375,7 +431,16 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
width: width,
|
||||
};
|
||||
|
||||
const rowHeight = height - (messageBoxVisible ? this.messageBoxHeight() : 0);
|
||||
this.styles_.messageBox = {
|
||||
width: width,
|
||||
height: this.messageBoxHeight(),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 10,
|
||||
backgroundColor: theme.warningBackgroundColor,
|
||||
};
|
||||
|
||||
const rowHeight = height - (messageBoxVisible ? this.styles_.messageBox.height : 0);
|
||||
|
||||
this.styles_.rowHeight = rowHeight;
|
||||
|
||||
@@ -391,12 +456,254 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
return this.styles_;
|
||||
}
|
||||
|
||||
public renderNotification() {
|
||||
return <WarningBanner
|
||||
height={this.messageBoxHeight()}
|
||||
onShow={() => this.setState({ messageBoxVisible: true })}
|
||||
onHide={() => this.setState({ messageBoxVisible: false })}
|
||||
/>;
|
||||
private renderNotificationMessage(message: string, callForAction: string = null, callForActionHandler: ()=> void = null, callForAction2: string = null, callForActionHandler2: ()=> void = null) {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const urlStyle: React.CSSProperties = { color: theme.colorWarnUrl, textDecoration: 'underline' };
|
||||
|
||||
if (!callForAction) return <span>{message}</span>;
|
||||
|
||||
const cfa = (
|
||||
<a href="#" style={urlStyle} onClick={() => callForActionHandler()}>
|
||||
{callForAction}
|
||||
</a>
|
||||
);
|
||||
|
||||
const cfa2 = !callForAction2 ? null : (
|
||||
<a href="#" style={urlStyle} onClick={() => callForActionHandler2()}>
|
||||
{callForAction2}
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!callForAction2 && message.includes(callForAction)) {
|
||||
const actionIndex = message.indexOf(callForAction);
|
||||
return (
|
||||
<span>
|
||||
{message.substring(0, actionIndex)}
|
||||
{cfa}
|
||||
{message.substring(actionIndex + callForAction.length)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{message}{callForAction ? ' ' : ''}
|
||||
{cfa}{callForAction2 ? ' / ' : ''}{cfa2}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
public renderNotification(theme: ThemeStyle, styles: Record<string, React.CSSProperties>) {
|
||||
if (!this.messageBoxVisible()) return null;
|
||||
|
||||
const onViewStatusScreen = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Status',
|
||||
});
|
||||
};
|
||||
|
||||
const onViewEncryptionConfigScreen = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Config',
|
||||
props: {
|
||||
defaultSection: 'encryption',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onViewJoplinCloudLoginScreen = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'JoplinCloudLogin',
|
||||
});
|
||||
};
|
||||
|
||||
const onDisableSync = () => {
|
||||
Setting.setValue('sync.target', null);
|
||||
};
|
||||
|
||||
const onViewSyncSettingsScreen = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Config',
|
||||
props: {
|
||||
defaultSection: 'sync',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onDownloadAppleSiliconVersion = () => {
|
||||
// The website should redirect to the correct version
|
||||
shim.openUrl('https://joplinapp.org/download/');
|
||||
};
|
||||
|
||||
const onCheckForUpdates = () => {
|
||||
void checkForUpdates(false, bridge().mainWindow(), { includePreReleases: false });
|
||||
};
|
||||
|
||||
const onRestartAndUpgrade = async () => {
|
||||
Setting.setValue('sync.upgradeState', Setting.SYNC_UPGRADE_STATE_MUST_DO);
|
||||
await Setting.saveAll();
|
||||
await restart();
|
||||
};
|
||||
|
||||
const onDisableSafeModeAndRestart = async () => {
|
||||
Setting.setValue('isSafeMode', false);
|
||||
await Setting.saveAll();
|
||||
await restart();
|
||||
};
|
||||
|
||||
const onInvitationRespond = async (shareUserId: string, folderId: string, masterKey: MasterKeyEntity, accept: boolean) => {
|
||||
await invitationRespond(shareUserId, folderId, masterKey, accept);
|
||||
};
|
||||
|
||||
let msg = null;
|
||||
|
||||
// When adding something here, don't forget to update the condition in
|
||||
// this.messageBoxVisible()
|
||||
|
||||
if (this.props.isSafeMode) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('Safe mode is currently active. Note rendering and all plugins are temporarily disabled.'),
|
||||
_('Disable safe mode and restart'),
|
||||
onDisableSafeModeAndRestart,
|
||||
);
|
||||
} else if (this.props.hasMissingSyncCredentials) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('The synchronisation password is missing.'),
|
||||
_('Set the password'),
|
||||
onViewSyncSettingsScreen,
|
||||
);
|
||||
} else if (this.props.shouldUpgradeSyncTarget) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('The sync target needs to be upgraded before Joplin can sync. The operation may take a few minutes to complete and the app needs to be restarted. To proceed please click on the link.'),
|
||||
_('Restart and upgrade'),
|
||||
onRestartAndUpgrade,
|
||||
);
|
||||
} else if (this.props.hasDisabledEncryptionItems) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('Some items cannot be decrypted.'),
|
||||
_('View them now'),
|
||||
onViewStatusScreen,
|
||||
);
|
||||
} else if (this.props.showNeedUpgradingMasterKeyMessage) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('One of your master keys use an obsolete encryption method.'),
|
||||
_('View them now'),
|
||||
onViewEncryptionConfigScreen,
|
||||
);
|
||||
} else if (this.props.showShouldReencryptMessage) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('The default encryption method has been changed, you should re-encrypt your data.'),
|
||||
_('More info'),
|
||||
onViewEncryptionConfigScreen,
|
||||
);
|
||||
} else if (this.showShareInvitationNotification(this.props)) {
|
||||
const invitation = this.props.shareInvitations.find(inv => inv.status === 0);
|
||||
const sharer = invitation.share.user;
|
||||
|
||||
msg = this.renderNotificationMessage(
|
||||
_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email),
|
||||
_('Accept'),
|
||||
() => onInvitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, true),
|
||||
_('Reject'),
|
||||
() => onInvitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, false),
|
||||
);
|
||||
} else if (this.props.hasDisabledSyncItems) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('Some items cannot be synchronised.'),
|
||||
_('View them now'),
|
||||
onViewStatusScreen,
|
||||
);
|
||||
} else if (this.props.showMissingMasterKeyMessage) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('One or more master keys need a password.'),
|
||||
_('Set the password'),
|
||||
onViewEncryptionConfigScreen,
|
||||
);
|
||||
} else if (this.props.mustUpgradeAppMessage) {
|
||||
if (!this.props.syncTargetAppMinVersion) {
|
||||
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
|
||||
} else if (this.state.didSyncTargetAppMinVersionReleaseLoadFail) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_(
|
||||
'In order to synchronise, Please upgrade your application to version %s. Joplin could not check update information.',
|
||||
this.props.syncTargetAppMinVersion,
|
||||
),
|
||||
);
|
||||
} else if (this.state.syncTargetAppMinVersionIsRelease === false && shim.isLinux()) {
|
||||
const callForAction = _('Download it from GitHub Releases');
|
||||
msg = this.renderNotificationMessage(
|
||||
_(
|
||||
'In order to synchronise, Please upgrade your application to version %s: %s or update it using your package manager',
|
||||
this.props.syncTargetAppMinVersion,
|
||||
callForAction,
|
||||
),
|
||||
callForAction,
|
||||
() => shim.openUrl('https://github.com/laurent22/joplin/releases'),
|
||||
);
|
||||
} else if (this.state.syncTargetAppMinVersionIsRelease !== null) {
|
||||
const isTargetPreRelease = this.state.syncTargetAppMinVersionIsRelease === false;
|
||||
const callForAction = isTargetPreRelease ? _('Download it from GitHub Releases') : _('Check for updates');
|
||||
msg = this.renderNotificationMessage(
|
||||
_(
|
||||
'In order to synchronise, Please upgrade your application to version %s: %s',
|
||||
this.props.syncTargetAppMinVersion,
|
||||
callForAction,
|
||||
),
|
||||
callForAction,
|
||||
isTargetPreRelease ? () => shim.openUrl('https://github.com/laurent22/joplin/releases') : onCheckForUpdates,
|
||||
);
|
||||
} else {
|
||||
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
|
||||
}
|
||||
} else if (this.props.shouldSwitchToAppleSiliconVersion) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('You are running the Intel version of Joplin on an Apple Silicon processor. Download the Apple Silicon one for better performance.'),
|
||||
_('Download it now'),
|
||||
onDownloadAppleSiliconVersion,
|
||||
);
|
||||
} else if (this.props.showInvalidJoplinCloudCredential) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('Your Joplin Cloud credentials are invalid, please login.'),
|
||||
_('Login to Joplin Cloud.'),
|
||||
onViewJoplinCloudLoginScreen,
|
||||
_('Disable synchronisation'),
|
||||
onDisableSync,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.messageBox}>
|
||||
<span
|
||||
style={theme.textStyle}
|
||||
role='alert'
|
||||
// role='alert' has an implicit aria-live='assertive', which tells screen readers that changes
|
||||
// to the warning's content should be announced as soon as possible. However, since it's generally
|
||||
// okay for announcements related to these notifications to be delayed, use aria-live='polite'.
|
||||
aria-live='polite'
|
||||
>{msg}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public messageBoxVisible(props: Props = null) {
|
||||
if (!props) props = this.props;
|
||||
return props.hasDisabledSyncItems ||
|
||||
props.showMissingMasterKeyMessage ||
|
||||
props.hasMissingSyncCredentials ||
|
||||
props.showNeedUpgradingMasterKeyMessage ||
|
||||
props.showShouldReencryptMessage ||
|
||||
props.hasDisabledEncryptionItems ||
|
||||
this.props.shouldUpgradeSyncTarget ||
|
||||
props.isSafeMode ||
|
||||
this.showShareInvitationNotification(props) ||
|
||||
this.props.needApiAuth ||
|
||||
!!this.props.mustUpgradeAppMessage ||
|
||||
props.showInvalidJoplinCloudCredential ||
|
||||
props.shouldSwitchToAppleSiliconVersion;
|
||||
}
|
||||
|
||||
private resizableLayout_resize(event: { layout: LayoutItem }) {
|
||||
@@ -461,9 +768,9 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
...this.props.style,
|
||||
};
|
||||
const styles = this.styles(this.props.themeId, style.width, style.height, this.state.messageBoxVisible);
|
||||
const styles = this.styles(this.props.themeId, style.width, style.height, this.messageBoxVisible());
|
||||
|
||||
const messageComp = this.renderNotification();
|
||||
const messageComp = this.renderNotification(theme, styles);
|
||||
|
||||
const layoutComp = this.props.mainLayout ? (
|
||||
<ResizableLayout
|
||||
@@ -491,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>
|
||||
@@ -499,20 +807,36 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const syncInfo = localSyncInfoFromState(state);
|
||||
const showNeedUpgradingEnabledMasterKeyMessage = !!EncryptionService.instance().masterKeysThatNeedUpgrading(syncInfo.masterKeys.filter((k) => !!k.enabled)).length;
|
||||
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
hasDisabledSyncItems: state.hasDisabledSyncItems,
|
||||
hasDisabledEncryptionItems: state.hasDisabledEncryptionItems,
|
||||
showMissingMasterKeyMessage: showMissingMasterKeyMessage(syncInfo, state.notLoadedMasterKeys),
|
||||
showNeedUpgradingMasterKeyMessage: showNeedUpgradingEnabledMasterKeyMessage,
|
||||
showShouldReencryptMessage: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES,
|
||||
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
|
||||
hasMissingSyncCredentials: shouldShowMissingPasswordWarning(state.settings['sync.target'], state.settings),
|
||||
plugins: state.pluginService.plugins,
|
||||
pluginHtmlContents: state.pluginService.pluginHtmlContents,
|
||||
hasNotesBeingSaved: stateUtils.hasNotesBeingSaved(state),
|
||||
layoutMoveMode: state.layoutMoveMode,
|
||||
mainLayout: state.mainLayout,
|
||||
shareInvitations: state.shareService.shareInvitations,
|
||||
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
|
||||
isSafeMode: state.settings.isSafeMode,
|
||||
enableLegacyMarkdownEditor: state.settings['editor.legacyMarkdown'],
|
||||
needApiAuth: state.needApiAuth,
|
||||
isResettingLayout: state.isResettingLayout,
|
||||
lastDeletion: state.lastDeletion,
|
||||
lastDeletionNotificationTime: state.lastDeletionNotificationTime,
|
||||
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
|
||||
syncTargetAppMinVersion: syncInfo.appMinVersion,
|
||||
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
|
||||
toast: state.toast,
|
||||
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && shim.isMac() && process.arch !== 'arm64',
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,78 +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) => {
|
||||
// A null id makes handleGetCurrentNote take its x-error branch, which reaches
|
||||
// respond() without touching the database.
|
||||
commandUtils.store = {
|
||||
getState: () => ({ selectedNoteIds: noteId ? [noteId] : [] }),
|
||||
};
|
||||
};
|
||||
|
||||
describe('handleCallbackUrl', () => {
|
||||
beforeEach(() => {
|
||||
mockOpenExternal.mockClear();
|
||||
setSelectedNoteId(null);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'ms-msdt:/id PCWDiagnostic',
|
||||
'search-ms:query=secret',
|
||||
'shell:startup',
|
||||
'file:///etc/passwd',
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'mailto:someone@example.com',
|
||||
'http://example.com/cb',
|
||||
'https://example.com/cb',
|
||||
'unknown-app://attacker.example.com/cb',
|
||||
'unknown-app:///hostless',
|
||||
'not a url',
|
||||
])('should not dispatch an unrecognised callback target (%s)', async (target) => {
|
||||
await withWarningSilenced(/Rejected malformed callback|Rejected callback target with host/, async () => {
|
||||
await executeCallbackUrl(`joplin://x-callback-url/getCurrentNote?x-error=${encodeURIComponent(target)}`);
|
||||
}, { requireWarning: true });
|
||||
expect(mockOpenExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
'hook://x-callback-url/setCurrentNode',
|
||||
'drafts://x-callback-url/create',
|
||||
'bear://x-callback-url/open-note',
|
||||
'ulysses://x-callback-url/new-sheet',
|
||||
'new-app://x-callback-url/cb',
|
||||
'editorial://workflow-callback',
|
||||
'things:///add',
|
||||
])('should dispatch a recognised callback target (%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('hook://x-callback-url/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,132 +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');
|
||||
|
||||
// Schemes of known apps, which are allowed whatever their callback format. Add
|
||||
// any legitimate app here - it's preferred over relying on the host check below.
|
||||
// From https://x-callback-url.com/apps/ and https://app-talk.com/
|
||||
const knownCallbackProtocols = [
|
||||
'agenda:', 'airmail:', 'bear:', 'beorg:', 'byword:', 'calca:', 'copied:',
|
||||
'dayone:', 'devonthink:', 'drafts:', 'drafts5:', 'due:', 'editorial:',
|
||||
'fantastical2:', 'gladys:', 'hook:', 'instapaper:', 'launcher:', 'omnifocus:',
|
||||
'omnifocus3:', 'onewriter:', 'opener:', 'outlinely:', 'overcast:', 'prizmo:',
|
||||
'pyto:', 'scriptable:', 'shortcutsiosopen:', 'terminology:', 'textastic:',
|
||||
'things:', 'timepage:', 'todoist:', 'trello:', 'twodo:', 'ulysses:',
|
||||
'working-copy:', 'x-devonthink:',
|
||||
];
|
||||
|
||||
const respond = (target: string, params: Record<string, string> = {}) => {
|
||||
if (!target) return;
|
||||
|
||||
// A joplin:// URL can be triggered from a web page and the target is passed to
|
||||
// the OS URI dispatcher, so it must not reach an arbitrary handler.
|
||||
let url;
|
||||
try {
|
||||
url = new URL(target);
|
||||
} catch (error) {
|
||||
// The target is untrusted and may contain secrets, so don't log it.
|
||||
logger.warn('Rejected malformed callback target');
|
||||
return;
|
||||
}
|
||||
if (url.host !== 'x-callback-url' && !knownCallbackProtocols.includes(url.protocol)) {
|
||||
logger.warn(`Rejected callback target with host "${url.host}" and scheme "${url.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';
|
||||
@@ -32,7 +31,6 @@ import NavService from '@joplin/lib/services/NavService';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { ImportCommandOptions } from './WindowCommandsAndDialogs/commands/importFrom';
|
||||
import { FileSystemItem } from '@joplin/lib/services/interop/types';
|
||||
import { chatAvailability } from '@joplin/lib/services/ai/availability';
|
||||
|
||||
const logger = Logger.create('MenuBar');
|
||||
|
||||
@@ -186,7 +184,6 @@ interface Props {
|
||||
secondaryWindowFocused: boolean;
|
||||
showMenuBar: boolean;
|
||||
syncPending: boolean;
|
||||
showAiChatMenuItem: boolean;
|
||||
}
|
||||
|
||||
const commandNames: string[] = menuCommandNames();
|
||||
@@ -534,7 +531,6 @@ function useMenu(props: Props) {
|
||||
// the following menu items will be available for all OS under Tools
|
||||
const toolsItemsAll = [
|
||||
menuItemDic.newWhiteboard,
|
||||
menuItemDic.addNoteToWhiteboard,
|
||||
separator(),
|
||||
{
|
||||
label: _('Note attachments...'),
|
||||
@@ -757,7 +753,6 @@ function useMenu(props: Props) {
|
||||
menuItemDic.toggleSideBar,
|
||||
shim.isMac() ? noItem : menuItemDic.toggleMenuBar,
|
||||
menuItemDic.toggleNoteList,
|
||||
{ ...menuItemDic.toggleAiChat, visible: props.showAiChatMenuItem },
|
||||
menuItemDic.toggleVisiblePanes,
|
||||
menuItemDic.toggleEditorPlugin,
|
||||
menuItemDic.toggleEditors,
|
||||
@@ -869,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,
|
||||
],
|
||||
@@ -1073,7 +1062,6 @@ function useMenu(props: Props) {
|
||||
switchProfileMenuItems,
|
||||
menuItemDic,
|
||||
props.syncPending,
|
||||
props.showAiChatMenuItem,
|
||||
]);
|
||||
|
||||
useMenuStates(menu, props);
|
||||
@@ -1161,7 +1149,6 @@ const mapStateToProps = (state: AppState): Partial<Props> => {
|
||||
noteListRendererId: state.settings['notes.listRendererId'],
|
||||
showMenuBar: state.settings.showMenuBar,
|
||||
syncPending: state.syncPending,
|
||||
showAiChatMenuItem: chatAvailability().available || !!state.settings['ai.chat.showToolbarButton'],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ const mapStateToProps = (state: AppState, connectProps: ConnectProps) => {
|
||||
'textCheckbox',
|
||||
'textHeading',
|
||||
'textHorizontalRule',
|
||||
'textBlockQuote',
|
||||
'editor.textTable',
|
||||
'insertDateTime',
|
||||
'toggleEditors',
|
||||
|
||||
@@ -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;
|
||||
|
||||