Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94168d33d6 | ||
|
|
451200c76c | ||
|
|
d865d1434e | ||
|
|
23c85cdecf | ||
|
|
280954e58f | ||
|
|
1dde6e8fb8 | ||
|
|
94ddfda2d6 | ||
|
|
30a017b800 | ||
|
|
0bce1ae02b | ||
|
|
2e45f22d85 | ||
|
|
ac56a7c729 | ||
|
|
585ec251b0 | ||
|
|
95375b1f11 | ||
|
|
410b5abab9 | ||
|
|
4a74b34e83 | ||
|
|
5c2601a283 | ||
|
|
7e23fc6446 | ||
|
|
e09c8e8527 | ||
|
|
4d33866822 | ||
|
|
b63bd50b9d | ||
|
|
f5b2d654d2 | ||
|
|
abb4f7e73f | ||
|
|
4571f69389 | ||
|
|
cf977d3839 | ||
|
|
cf57e59325 | ||
|
|
7dc1efb1ec | ||
|
|
1935d47755 | ||
|
|
4724e3c0d8 | ||
|
|
c4c49b6d28 | ||
|
|
eba62ede92 | ||
|
|
95f21039bf | ||
|
|
1515e8b81c | ||
|
|
3dc0e2c0f6 | ||
|
|
d0d35c173a | ||
|
|
71f403d0db | ||
|
|
7c0e5654c6 | ||
|
|
c4254c3774 | ||
|
|
e255ac99be | ||
|
|
cd9f2728bd | ||
|
|
4c0fbaae67 | ||
|
|
44031f1cbb | ||
|
|
ef61ac2043 | ||
|
|
3fb835a9bf | ||
|
|
a28eb47c1a | ||
|
|
e43be19dbb | ||
|
|
6753a76b99 | ||
|
|
79eccc2d67 | ||
|
|
057596c23c | ||
|
|
f3d8e33847 | ||
|
|
0d4947bc8e | ||
|
|
321dcdbbd5 | ||
|
|
6b5e3db472 | ||
|
|
17d3e378d2 | ||
|
|
c17f98efb4 | ||
|
|
6de5ec074a | ||
|
|
c87f694dc7 | ||
|
|
21d0a8ad71 | ||
|
|
553ddf6ec1 | ||
|
|
7c619e4744 | ||
|
|
3f7dde0045 | ||
|
|
052583a8fb | ||
|
|
c51a2cb7e1 | ||
|
|
8905795ed1 | ||
|
|
9c820ef8e2 | ||
|
|
6bca95dbdc | ||
|
|
7da9753f71 | ||
|
|
03c2fa6b07 | ||
|
|
02f5db5bc8 | ||
|
|
cb2f658b6c | ||
|
|
26ce406e49 | ||
|
|
33449150e5 | ||
|
|
b0affc0a30 | ||
|
|
00d62e8700 | ||
|
|
5c7eab0808 | ||
|
|
d5a6aeaa75 | ||
|
|
bc8bbb85dd | ||
|
|
e972fd2397 | ||
|
|
ab241bd8d6 | ||
|
|
9e4ca948be | ||
|
|
9f84bda5db | ||
|
|
5dd5842401 | ||
|
|
aaa6f8e3ae | ||
|
|
618f7e4ff7 | ||
|
|
a47a208aa4 | ||
|
|
850030faf6 | ||
|
|
5bffa4b728 | ||
|
|
6bfd8bb5d9 | ||
|
|
7042c2233c | ||
|
|
0642345314 | ||
|
|
e8d15e7f92 | ||
|
|
d32307364c | ||
|
|
ca681c57c6 | ||
|
|
97ab341275 | ||
|
|
cd32d862c4 | ||
|
|
b94994b6e7 | ||
|
|
90d810c0c6 | ||
|
|
8db668e22f | ||
|
|
b04a5f0489 | ||
|
|
0164f34d8f | ||
|
|
42cd68983b | ||
|
|
f15506b873 | ||
|
|
2603333ae5 | ||
|
|
1008705034 | ||
|
|
c64cfb1943 | ||
|
|
2a901dea1d | ||
|
|
c199af7ca8 | ||
|
|
5a48b72d43 | ||
|
|
7fb04b0a11 | ||
|
|
b1c43ccce9 | ||
|
|
def3e7928f | ||
|
|
44bd68dbe3 | ||
|
|
9cbf49b260 | ||
|
|
cd996c2eff | ||
|
|
06ebfbacde | ||
|
|
a653fe877d | ||
|
|
1499d4f11f | ||
|
|
a560f14917 | ||
|
|
70665897b5 | ||
|
|
4c0e2bc0e2 | ||
|
|
3cb9ad062d | ||
|
|
c5f185aacd | ||
|
|
80af3696f8 | ||
|
|
625f474b6f | ||
|
|
d8d9218b76 | ||
|
|
e41516e669 | ||
|
|
97b655510a | ||
|
|
c58052c48b | ||
|
|
a15bead867 | ||
|
|
6ae9353849 | ||
|
|
a03416829c | ||
|
|
83059a860e | ||
|
|
d9f0b4206a | ||
|
|
c32c50fbe9 | ||
|
|
4978154dc1 | ||
|
|
6e28b0a40d | ||
|
|
61eecacf49 | ||
|
|
dbdc233bbb | ||
|
|
32fde22f54 | ||
|
|
1d6beb0443 | ||
|
|
c795a55895 | ||
|
|
49b677ea33 | ||
|
|
7fdcf1eabd | ||
|
|
227dbdedfc | ||
|
|
b15d55765a | ||
|
|
56ce951f37 | ||
|
|
f2a3569d72 | ||
|
|
a3d94d4d8e | ||
|
|
d1ac85c0d4 | ||
|
|
a7eaddeeee | ||
|
|
56ea34fbe4 | ||
|
|
c81243852d | ||
|
|
6473be223a | ||
|
|
9e4b920cf1 | ||
|
|
552671dde8 | ||
|
|
24e5778256 | ||
|
|
8cc854c9be | ||
|
|
2bddab0d04 | ||
|
|
76ea0093ca | ||
|
|
e76fed31df | ||
|
|
2b217ce3f9 | ||
|
|
f4ecbed4e6 | ||
|
|
a859a62506 | ||
|
|
32578c761e | ||
|
|
27da730f04 | ||
|
|
0369f2fd84 | ||
|
|
b90691d562 | ||
|
|
04f53e0c69 | ||
|
|
2c33e1db44 | ||
|
|
ef70b72d38 | ||
|
|
981a03c5c9 | ||
|
|
4f4494fe1b | ||
|
|
de42dc1e48 | ||
|
|
f611833b08 | ||
|
|
6459e6fffd | ||
|
|
fd7f557552 | ||
|
|
a2fcac2e08 | ||
|
|
e5ebf70ced | ||
|
|
7fc4abe2ce | ||
|
|
468c40d09b | ||
|
|
0342c649bd | ||
|
|
e2036e4d60 | ||
|
|
a661b8897a | ||
|
|
7c0c50eead | ||
|
|
41b5934cb0 | ||
|
|
141c451ff9 | ||
|
|
1e9f72738a | ||
|
|
362d613c3e | ||
|
|
94084f3677 | ||
|
|
ede6a35169 | ||
|
|
b1979eda7c | ||
|
|
59ecb92cd2 | ||
|
|
77a8249388 | ||
|
|
2fd7ac6573 | ||
|
|
f97765f332 | ||
|
|
1348db9624 | ||
|
|
e356e2b508 | ||
|
|
26bff74d08 | ||
|
|
f6b19f80e4 | ||
|
|
e55856d74c | ||
|
|
e0f51d8f43 | ||
|
|
23504fcf7e | ||
|
|
d9d14ce692 | ||
|
|
7eae6cf3fa | ||
|
|
7ff6bf48b5 | ||
|
|
8a1669c76d | ||
|
|
6b09f9d378 | ||
|
|
5d733e3268 | ||
|
|
003233a225 | ||
|
|
6470029203 | ||
|
|
71b0c2027a | ||
|
|
3a1cd0edc8 | ||
|
|
076ffc42e8 | ||
|
|
055f92b9f7 | ||
|
|
e2f30308f8 | ||
|
|
71d4b09d48 | ||
|
|
81f8bfed39 | ||
|
|
7e73a2a271 | ||
|
|
c2d4d37403 | ||
|
|
e6a99b978a | ||
|
|
ac2e04204b | ||
|
|
e611eb4f0c | ||
|
|
5ae6b7b6c6 | ||
|
|
82652953a8 | ||
|
|
f4069b5f02 | ||
|
|
c27de4f34e | ||
|
|
b6c2efc567 | ||
|
|
c07d94cc95 | ||
|
|
ac5b30aed7 | ||
|
|
39a0ce387c | ||
|
|
70c2d357b9 | ||
|
|
a92b37e3e7 | ||
|
|
e67e4d7685 | ||
|
|
7969c8fce9 | ||
|
|
fa25937bc3 | ||
|
|
21a7dd4307 | ||
|
|
c7dea58402 | ||
|
|
5df15f3737 | ||
|
|
1090c509e4 | ||
|
|
518fbb40f8 | ||
|
|
517839d99a | ||
|
|
c7a4f4c031 | ||
|
|
46303619c3 | ||
|
|
c048bd9464 | ||
|
|
a0d83c5115 | ||
|
|
5755024a31 | ||
|
|
65d0a81a37 | ||
|
|
77b2ecf567 | ||
|
|
3f23202ec1 | ||
|
|
6fb816d014 | ||
|
|
f49eb1a597 | ||
|
|
d4d9df18d1 | ||
|
|
a903027344 |
No files matched your search
@@ -26,6 +26,7 @@ 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,6 +8,22 @@ 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
|
||||
@@ -20,12 +36,32 @@ 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 @DistArgs
|
||||
yarn dist @archArgs @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|Windows|Linux|macOS|Cli|Clipper|Server|Transcribe|Plugins|Api|Cloud|Tools|Chore|CI|Doc)';
|
||||
const prefix = '(Desktop|Mobile|Android|iOS|Web|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,3 +32,12 @@ 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
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/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,6 +165,18 @@ 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
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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@v2
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.CODERABBIT_CLEANUP_APP_ID }}
|
||||
private-key: ${{ secrets.CODERABBIT_CLEANUP_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -10,7 +10,12 @@ jobs:
|
||||
matrix:
|
||||
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
|
||||
# https://github.com/actions/runner-images/issues/6709
|
||||
os: [macos-15-intel, ubuntu-22.04, windows-2025, ubuntu-22.04-arm]
|
||||
#
|
||||
# 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]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -45,7 +50,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@v3
|
||||
- uses: docker/login-action@v4
|
||||
if: runner.os == 'Linux' && (startsWith(github.ref, 'refs/tags/server-v') || startsWith(github.ref, 'refs/tags/transcribe-v'))
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -39,7 +39,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@v6
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report-${{ matrix.os }}
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
retention-days: 7
|
||||
Mobile:
|
||||
if: github.repository == 'laurent22/joplin'
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-26
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup build environment
|
||||
|
||||
@@ -164,6 +164,7 @@ 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
|
||||
@@ -182,6 +183,7 @@ 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
|
||||
@@ -212,6 +214,7 @@ 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
|
||||
@@ -223,6 +226,7 @@ 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
|
||||
@@ -510,6 +514,7 @@ 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
|
||||
@@ -551,6 +556,7 @@ 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
|
||||
@@ -603,6 +609,7 @@ 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
|
||||
@@ -612,6 +619,7 @@ 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/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
|
||||
@@ -620,9 +628,11 @@ packages/app-desktop/integration-tests/models/NoteEditor/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/NoteViewer.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/RichTextEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen/SyncTab.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
|
||||
@@ -698,6 +708,7 @@ 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
|
||||
@@ -816,6 +827,11 @@ 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
|
||||
@@ -848,6 +864,7 @@ 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
|
||||
@@ -925,6 +942,7 @@ 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
|
||||
@@ -960,7 +978,6 @@ 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
|
||||
@@ -1042,6 +1059,7 @@ 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
|
||||
@@ -1604,6 +1622,7 @@ 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
|
||||
@@ -1675,8 +1694,14 @@ 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
|
||||
@@ -1771,6 +1796,8 @@ 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
|
||||
@@ -1933,6 +1960,7 @@ 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
|
||||
@@ -1985,6 +2013,7 @@ 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
|
||||
@@ -2037,6 +2066,8 @@ 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
|
||||
|
||||
@@ -190,6 +190,7 @@ 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
|
||||
@@ -208,6 +209,7 @@ 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
|
||||
@@ -238,6 +240,7 @@ 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
|
||||
@@ -249,6 +252,7 @@ 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
|
||||
@@ -536,6 +540,7 @@ 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
|
||||
@@ -577,6 +582,7 @@ 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
|
||||
@@ -629,6 +635,7 @@ 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
|
||||
@@ -638,6 +645,7 @@ 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/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
|
||||
@@ -646,9 +654,11 @@ packages/app-desktop/integration-tests/models/NoteEditor/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/NoteViewer.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditor/RichTextEditor.js
|
||||
packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen/SyncTab.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
|
||||
@@ -724,6 +734,7 @@ 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
|
||||
@@ -842,6 +853,11 @@ 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
|
||||
@@ -874,6 +890,7 @@ 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
|
||||
@@ -951,6 +968,7 @@ 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
|
||||
@@ -986,7 +1004,6 @@ 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
|
||||
@@ -1068,6 +1085,7 @@ 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
|
||||
@@ -1630,6 +1648,7 @@ 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
|
||||
@@ -1701,8 +1720,14 @@ 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
|
||||
@@ -1797,6 +1822,8 @@ 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
|
||||
@@ -1959,6 +1986,7 @@ 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
|
||||
@@ -2011,6 +2039,7 @@ 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
|
||||
@@ -2063,6 +2092,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
*/
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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"
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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"
|
||||
@@ -1,37 +0,0 @@
|
||||
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,6 +1,25 @@
|
||||
# Fixes sync issues caused by locale-sensitive lowercasing
|
||||
# of HTTP headers.
|
||||
# See https://github.com/laurent22/joplin/issues/10681
|
||||
# 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 {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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") {
|
||||
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 700 KiB |
@@ -1,3 +1,6 @@
|
||||
// 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,
|
||||
@@ -25,7 +28,21 @@ async function getOs() {
|
||||
}
|
||||
}
|
||||
|
||||
if (navigator.appVersion.indexOf("Win")!=-1) return "windows";
|
||||
// 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("X11")!=-1) return "linux";
|
||||
if (navigator.appVersion.indexOf("Linux")!=-1) return "linux";
|
||||
return null;
|
||||
@@ -78,7 +95,12 @@ async function setupDownloadPage() {
|
||||
$('.page-download .get-it-desktop a').each(function() {
|
||||
const href = $(this).attr('href');
|
||||
|
||||
if (href.indexOf('-Setup') > 0) downloadLinks['windows'] = 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('.dmg') > 0) downloadLinks['macOs'] = href;
|
||||
if (href.indexOf('arm64.DMG') > 0) downloadLinks['macOsM1'] = href;
|
||||
if (href.indexOf('.AppImage') > 0) downloadLinks['linux'] = href;
|
||||
@@ -131,7 +153,7 @@ const localePathOverrides = {
|
||||
};
|
||||
|
||||
// List of supported language codes
|
||||
const supportedLanguages = ['fr', 'de', 'zh'];
|
||||
const supportedLanguages = ['fr', 'de', 'hr', 'zh'];
|
||||
|
||||
function getLocalePath(langCode) {
|
||||
const pathPrefix = localePathOverrides[langCode] || langCode;
|
||||
|
||||
@@ -1,4 +1,83 @@
|
||||
<?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>
|
||||
<?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>
|
||||
<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>
|
||||
@@ -485,102 +564,4 @@ 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><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>
|
||||
]]></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>
|
||||
@@ -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 us can have access to 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 we can access your data.
|
||||
</p>
|
||||
<br />
|
||||
<br />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Build stage
|
||||
# =============================================================================
|
||||
|
||||
FROM node:24 AS builder
|
||||
FROM node:24.22.1 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-slim
|
||||
FROM node:24.22.1-slim
|
||||
|
||||
ARG user=joplin
|
||||
RUN useradd --create-home --shell /bin/bash $user
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-bookworm
|
||||
FROM node:24.22.1-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.2.1-cudnn-runtime-ubuntu22.04
|
||||
FROM nvidia/cuda:13.3.0-cudnn-runtime-ubuntu22.04
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
|
||||
@@ -262,42 +262,6 @@ 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
|
||||
@@ -310,13 +274,7 @@ if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cin
|
||||
DATA_HOME=${XDG_DATA_HOME:-~/.local/share}
|
||||
DESKTOP_FILE_LOCATION="$DATA_HOME/applications"
|
||||
|
||||
# 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
|
||||
STARTUP_WM_CLASS=appimagekit-joplin
|
||||
|
||||
# Only delete the desktop file if it will be replaced
|
||||
rm -f "$DESKTOP_FILE_LOCATION/appimagekit-joplin.desktop"
|
||||
@@ -330,10 +288,8 @@ 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" ${SANDBOXPARAM} %u
|
||||
Exec=env APPIMAGELAUNCHER_DISABLE=TRUE "${INSTALL_DIR}/Joplin.AppImage" %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://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>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 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.
|
||||
@@ -10,6 +13,14 @@ 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.3",
|
||||
"python": "3.14.4",
|
||||
"bat": "latest",
|
||||
"electron": {
|
||||
"version": "latest",
|
||||
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"git": "2.52.0",
|
||||
"git": "2.53.0",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
||||
@@ -33,3 +33,33 @@ 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.12",
|
||||
"yarn": "4.12.0"
|
||||
"node": ">=22.19",
|
||||
"yarn": "4.16.0"
|
||||
},
|
||||
"scripts": {
|
||||
"buildApiDoc": "yarn workspace joplin start apidoc ../../readme/api/references/rest_api.md",
|
||||
@@ -77,51 +77,50 @@
|
||||
"@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.0",
|
||||
"@typescript-eslint/parser": "8.60.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.1",
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"cspell": "5.21.2",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-interactive": "14.0.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jest": "29.15.2",
|
||||
"eslint-plugin-jest": "29.15.4",
|
||||
"eslint-plugin-promise": "7.3.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "11.3.5",
|
||||
"fs-extra": "11.3.6",
|
||||
"glob": "11.1.0",
|
||||
"globals": "17.6.0",
|
||||
"globals": "17.7.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": "9.1.0",
|
||||
"npm-package-json-lint": "10.4.1",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"eslint-plugin-github": "4.10.2",
|
||||
"http-server": "14.1.1",
|
||||
"node-gyp": "11.5.0",
|
||||
"node-gyp": "12.4.0",
|
||||
"nodemon": "3.1.14"
|
||||
},
|
||||
"packageManager": "yarn@4.16.0",
|
||||
"resolutions": {
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/lint": "6.9.5",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"onnxruntime-node": "1.27.0",
|
||||
"eslint": "patch:eslint@9.39.4#./.yarn/patches/eslint-npm-9.39.4-81a84865c0.patch",
|
||||
"yauzl": "3.3.1",
|
||||
"yauzl": "3.4.0",
|
||||
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
||||
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
||||
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
|
||||
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
|
||||
"chokidar@^2.0.0": "3.5.3",
|
||||
"chokidar@^2.0.0": "3.6.0",
|
||||
"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",
|
||||
"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",
|
||||
@@ -130,9 +129,13 @@
|
||||
"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": "11.2.0",
|
||||
"node-forge@npm:^1": "1.3.2",
|
||||
"node-forge@npm:^1.3.0": "1.3.2",
|
||||
"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",
|
||||
"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",
|
||||
@@ -146,6 +149,7 @@
|
||||
"@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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ class ResourceServer {
|
||||
this.logger().error('Resource server:', error);
|
||||
});
|
||||
|
||||
this.server_.listen(this.port_);
|
||||
this.server_.listen(this.port_, '127.0.0.1');
|
||||
|
||||
enableServerDestroy(this.server_);
|
||||
|
||||
|
||||
@@ -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 { Writable } from 'stream';
|
||||
import { Stream, 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 MutableStdout;
|
||||
} as Stream.WritableOptions<Writable>) as MutableStdout;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, noteLockCipherTextStandIn } from '@joplin/lib/testing/test-utils';
|
||||
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
|
||||
const Command = require('./command-cat');
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('command-cat', () => {
|
||||
|
||||
it('should refuse to display a locked note', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
|
||||
const note = await Note.save({ title: 'hello', body: noteLockCipherTextStandIn(), is_locked: 1, parent_id: '' });
|
||||
|
||||
let output = '';
|
||||
const command = setupCommandForTesting(Command, (text: string) => { output += text; });
|
||||
|
||||
@@ -58,7 +58,7 @@ class Command extends BaseCommand {
|
||||
}
|
||||
});
|
||||
|
||||
inputStream.on('error', (error) => {
|
||||
inputStream.on('error', (error: Error) => {
|
||||
if (!isSettled) {
|
||||
isSettled = true;
|
||||
reject(error);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, noteLockCipherTextStandIn } from '@joplin/lib/testing/test-utils';
|
||||
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
|
||||
const Command = require('./command-edit');
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('command-edit', () => {
|
||||
|
||||
it('should refuse to edit a locked note without writing a temp file', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
|
||||
const 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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, noteLockCipherTextStandIn } from '@joplin/lib/testing/test-utils';
|
||||
import { setupCommandForTesting, setupApplication } from './utils/testUtils';
|
||||
const Command = require('./command-set');
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('command-set', () => {
|
||||
{ label: 'change a locked note when note lock is disabled', flagEnabled: false, expectedError: null, expectedTitle: 'renamed' },
|
||||
])('should $label', async ({ flagEnabled, expectedError, expectedTitle }) => {
|
||||
Setting.setValue('featureFlag.noteLock', flagEnabled);
|
||||
const note = await Note.save({ title: 'hello', body: 'ciphertext', is_locked: 1, parent_id: '' });
|
||||
const note = await Note.save({ title: 'hello', body: noteLockCipherTextStandIn(), is_locked: 1, parent_id: '' });
|
||||
|
||||
const command = setupCommandForTesting(Command);
|
||||
let error: string|null = null;
|
||||
|
||||
@@ -18,6 +18,8 @@ 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');
|
||||
|
||||
@@ -57,7 +59,7 @@ class Command extends BaseCommand {
|
||||
|
||||
if (this.syncTargetId_ === 3 || this.syncTargetId_ === 4) {
|
||||
// OneDrive
|
||||
this.oneDriveApiUtils_ = new OneDriveApiNodeUtils(syncTarget.api());
|
||||
this.oneDriveApiUtils_ = new OneDriveApiNodeUtils((syncTarget as SyncTargetOneDrive).api());
|
||||
const auth = await this.oneDriveApiUtils_.oauthDance({
|
||||
log: (s: string) => {
|
||||
return this.stdout(s);
|
||||
@@ -74,7 +76,7 @@ class Command extends BaseCommand {
|
||||
return true;
|
||||
} else if (syncTargetMd.name === 'dropbox') {
|
||||
// Dropbox
|
||||
const api = await syncTarget.api();
|
||||
const api = await (syncTarget as SyncTargetDropbox).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:'));
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"version": "3.7.1",
|
||||
"bin": "./main.js",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
"node": ">=22.19.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.5",
|
||||
"fs-extra": "11.3.6",
|
||||
"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.34.5",
|
||||
"sharp": "0.35.0",
|
||||
"sprintf-js": "1.1.3",
|
||||
"sqlite3": "5.1.6",
|
||||
"string-padding": "1.0.2",
|
||||
"strip-ansi": "6.0.1",
|
||||
"tcp-port-used": "1.0.2",
|
||||
"terminal-kit": "3.1.2",
|
||||
"tcp-port-used": "1.0.3",
|
||||
"terminal-kit": "3.1.4",
|
||||
"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": "18.19.130",
|
||||
"@types/node": "22.19.21",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
|
||||
@@ -2,7 +2,9 @@ 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', () => {
|
||||
|
||||
@@ -129,4 +131,54 @@ 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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
<p>Headings with nonbreaking spaces should be preserved:</p>
|
||||
<h2> </h2>
|
||||
<h2> </h2>
|
||||
<h3> Leading space</h3>
|
||||
<h2><code>a b</code></h2>
|
||||
@@ -0,0 +1,9 @@
|
||||
Headings with nonbreaking spaces should be preserved:
|
||||
|
||||
##
|
||||
|
||||
##
|
||||
|
||||
### Leading space
|
||||
|
||||
## `a b`
|
||||
@@ -228,17 +228,29 @@ export default class InteropServiceHelper {
|
||||
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));
|
||||
}
|
||||
|
||||
void CommandService.instance().execute('hideModalMessage');
|
||||
if (!keepModalOpen) void CommandService.instance().execute('hideModalMessage');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -100,6 +100,7 @@ export interface AppState extends State, AppWindowState {
|
||||
layoutMoveMode: boolean;
|
||||
startupPluginsLoaded: boolean;
|
||||
modalOverlayMessage: string|null;
|
||||
modalOverlayHasCloseButton: boolean;
|
||||
|
||||
// Extra reducer keys go here
|
||||
mainLayout: LayoutItem;
|
||||
@@ -143,6 +144,7 @@ export function createAppDefaultState(resourceEditWatcherDefaultState: Partial<A
|
||||
startupPluginsLoaded: false,
|
||||
isResettingLayout: false,
|
||||
modalOverlayMessage: null,
|
||||
modalOverlayHasCloseButton: false,
|
||||
aiStatus: defaultAiStatus(),
|
||||
...resourceEditWatcherDefaultState,
|
||||
};
|
||||
@@ -396,11 +398,15 @@ export default function(state: AppState, action: any) {
|
||||
}
|
||||
|
||||
case 'SHOW_MODAL_MESSAGE':
|
||||
newState = { ...newState, modalOverlayMessage: action.message };
|
||||
newState = {
|
||||
...newState,
|
||||
modalOverlayMessage: action.message,
|
||||
modalOverlayHasCloseButton: !!action.hasCloseButton,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'HIDE_MODAL_MESSAGE':
|
||||
newState = { ...newState, modalOverlayMessage: null };
|
||||
newState = { ...newState, modalOverlayMessage: null, modalOverlayHasCloseButton: false };
|
||||
break;
|
||||
|
||||
case 'NOTE_FILE_WATCHER_ADD':
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
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,6 +69,7 @@ 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();
|
||||
|
||||
@@ -162,6 +163,17 @@ 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();
|
||||
}
|
||||
@@ -169,6 +181,19 @@ 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);
|
||||
}
|
||||
@@ -658,7 +683,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).then(() => {
|
||||
void reg.scheduleSync(0).finally(() => {
|
||||
// Wait for the first sync before updating the notifications, since synchronisation
|
||||
// might change the notifications.
|
||||
void AlarmService.updateAllNotifications();
|
||||
|
||||
@@ -16,6 +16,8 @@ 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;
|
||||
@@ -495,6 +497,12 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<!-- 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>
|
||||
|
After Width: | Height: | Size: 628 B |
@@ -12,6 +12,7 @@ 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';
|
||||
@@ -39,6 +40,7 @@ const index: any[] = [
|
||||
openPrimaryAppInstance,
|
||||
openProfileDirectory,
|
||||
openSecondaryAppInstance,
|
||||
openSyncWizard,
|
||||
replaceMisspelling,
|
||||
restoreNoteRevision,
|
||||
showProfileEditor,
|
||||
|
||||
@@ -2,8 +2,11 @@ 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',
|
||||
@@ -18,14 +21,18 @@ export const runtime = (): CommandRuntime => {
|
||||
execute: async (context: CommandContext, noteId: string = null) => {
|
||||
noteId = noteId || stateUtils.selectedNoteId(context.state);
|
||||
|
||||
const note = await Note.load(noteId, { fields: ['parent_id'] });
|
||||
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;
|
||||
context.dispatch({
|
||||
type: 'WINDOW_OPEN',
|
||||
noteId,
|
||||
folderId: note.parent_id,
|
||||
folderId,
|
||||
windowId: `window-${noteId}-${idCounter++}`,
|
||||
defaultAppWindowState: {
|
||||
...createAppDefaultWindowState(),
|
||||
notes: [note],
|
||||
noteVisiblePanes: Setting.value('noteVisiblePanes'),
|
||||
editorCodeView: Setting.value('editor.codeView'),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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',
|
||||
};
|
||||
};
|
||||
@@ -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, SettingValueType, SyncStartupOperation } from '@joplin/lib/models/Setting';
|
||||
import Setting, { AppType, SettingMetadataSection, SettingsRecord, SettingValueType } from '@joplin/lib/models/Setting';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen';
|
||||
import NoteLockSettings from './controls/NoteLockSettings';
|
||||
@@ -25,10 +25,12 @@ import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
|
||||
import AiIndexStatus from './controls/AiIndexStatus';
|
||||
import AiStatus from './controls/AiStatus';
|
||||
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
|
||||
import SettingComponent, { UpdateSettingValueEvent } from './controls/SettingComponent';
|
||||
import SettingComponent 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 {
|
||||
@@ -42,8 +44,21 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// 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> {
|
||||
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> {
|
||||
|
||||
private rowStyle_: React.CSSProperties = null;
|
||||
|
||||
@@ -102,7 +117,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
});
|
||||
}
|
||||
}
|
||||
await shared.checkSyncConfig(this, this.state.settings);
|
||||
return await shared.checkSyncConfig(this, this.state.settings);
|
||||
}
|
||||
|
||||
public UNSAFE_componentWillMount() {
|
||||
@@ -122,35 +137,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
}
|
||||
|
||||
private async handleSettingButton(key: string) {
|
||||
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}`);
|
||||
}
|
||||
await shared.onSettingButtonPress(this, Setting.settingMetadata(key));
|
||||
}
|
||||
|
||||
public sectionByName(name: string) {
|
||||
@@ -222,7 +209,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
);
|
||||
}
|
||||
|
||||
public sectionToComponent(key: string, section: SettingMetadataSection, settings: Record<string, unknown>, selected: boolean) {
|
||||
public sectionToComponent(key: string, section: SettingMetadataSection, settings: shared.SettingsMap, selected: boolean) {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const searchMode = !!normalizeQuery(this.state.searchQuery);
|
||||
|
||||
@@ -308,24 +295,6 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
</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;
|
||||
|
||||
@@ -397,7 +366,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
);
|
||||
}
|
||||
|
||||
private onUpdateSettingValue = ({ key, value }: UpdateSettingValueEvent) => {
|
||||
private onUpdateSettingValue = ({ key, value }: UpdateSettingValueEvent<string>) => {
|
||||
const md = Setting.settingMetadata(key);
|
||||
if (md.needRestart) {
|
||||
this.setState({ needRestart: true });
|
||||
@@ -405,6 +374,10 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
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);
|
||||
};
|
||||
@@ -425,7 +398,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
}
|
||||
|
||||
private restartMessage() {
|
||||
return _('The application must be restarted for these changes to take effect.');
|
||||
return shared.restartMessage();
|
||||
}
|
||||
|
||||
private async restartApp() {
|
||||
@@ -471,7 +444,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
const searchMode = !!searchQuery;
|
||||
const sectionFilter = this.state.searchSectionFilter;
|
||||
|
||||
const style = {
|
||||
const style: React.CSSProperties = {
|
||||
...this.props.style,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
|
||||
@@ -124,6 +124,10 @@ 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;
|
||||
@@ -197,7 +201,7 @@ const NoteLockSettings: React.FC<Props> = props => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
<p className='reminder'><strong>{passwordReminder}</strong></p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -208,7 +212,7 @@ const NoteLockSettings: React.FC<Props> = props => {
|
||||
return (
|
||||
<div className='section'>
|
||||
<h2>{_('Key upgrade')}</h2>
|
||||
<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>
|
||||
<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>
|
||||
<div className='form'>
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Password')}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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,6 +11,8 @@ 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>> = {
|
||||
@@ -18,18 +20,13 @@ 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: (event: UpdateSettingValueEvent)=> void;
|
||||
onSettingButtonClick: (key: string)=> void;
|
||||
onUpdateSettingValue: OnUpdateSettingValue;
|
||||
onSettingButtonClick: (key: string)=> Promise<void>;
|
||||
renderSearchText?: (text: string)=> React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -363,10 +360,9 @@ const SettingComponent: React.FC<Props> = props => {
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
{labelComp}
|
||||
<Button
|
||||
level={ButtonLevel.Secondary}
|
||||
title={md.label()}
|
||||
onClick={md.onClick ? md.onClick : () => props.onSettingButtonClick(key)}
|
||||
<SettingButton
|
||||
settingKey={key}
|
||||
onSettingButtonClick={props.onSettingButtonClick}
|
||||
/>
|
||||
{descriptionComp}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
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, useInputPasswords, useNeedMasterPassword, usePasswordChecker, useStats, useToggleShowDisabledMasterKeys } from '@joplin/lib/components/EncryptionConfigScreen/utils';
|
||||
import { decryptedStatText, determineKeyPassword, dontReencryptData, enableEncryptionConfirmationMessages, onSavePasswordClick, onToggleEnabledClick, reencryptData, upgradeMasterKey, useHasUnknownEncryptionMethodKeysMessage, 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, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
|
||||
import { getDefaultMasterKey, getMasterPasswordStatusMessage, isKnownEncryptionMethod, 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,6 +76,7 @@ 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;
|
||||
@@ -173,6 +174,7 @@ 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) => {
|
||||
@@ -207,7 +209,9 @@ 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}>{passwordOk}</td>
|
||||
<td
|
||||
style={theme.textStyle}
|
||||
>{knownKeyType ? passwordOk : <span title={_('Unknown key format')}>❓</span>}</td>
|
||||
<td style={theme.textStyle}>
|
||||
<button style={theme.buttonStyle} onClick={() => onToggleEnabledClick(mk)}>{masterKeyEnabled(mk) ? _('Disable') : _('Enable')}</button>
|
||||
</td>
|
||||
@@ -456,7 +460,7 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
const buttonTitle = CommandService.instance().label('openMasterPasswordDialog');
|
||||
|
||||
const needPasswordMessage = !needMasterPassword ? null : (
|
||||
<p className="needpassword">
|
||||
<p className="warning">
|
||||
{_('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'))}
|
||||
@@ -468,12 +472,19 @@ 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>
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.manage-password-section > .needpassword {
|
||||
.manage-password-section > .warning {
|
||||
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 style={{ overflow: 'auto', fontFamily: 'sans-serif', padding: '5px 20px' }}>
|
||||
<div className='error-boundary'>
|
||||
<SwitchToNewEditorBanner isVisible={isLegacyEditorError} />
|
||||
<h1>Error</h1>
|
||||
{this.renderMessage()}
|
||||
|
||||
@@ -14,39 +14,31 @@ 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, ThemeStyle } from '@joplin/lib/theme';
|
||||
import { 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 { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import executeCallbackUrl from './MainScreen/handleCallbackUrl';
|
||||
import ElectronAppWrapper from '../ElectronAppWrapper';
|
||||
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
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 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;
|
||||
@@ -57,26 +49,13 @@ 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 {
|
||||
@@ -90,8 +69,7 @@ interface State {
|
||||
noteContentPropertiesDialogOptions: Record<string, unknown>;
|
||||
shareNoteDialogOptions: Record<string, unknown>;
|
||||
shareFolderDialogOptions: ShareFolderDialogOptions;
|
||||
syncTargetAppMinVersionIsRelease: boolean | null;
|
||||
didSyncTargetAppMinVersionReleaseLoadFail: boolean;
|
||||
messageBoxVisible: boolean;
|
||||
}
|
||||
|
||||
const StyledUserWebviewDialogContainer = styled.div`
|
||||
@@ -134,8 +112,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
visible: false,
|
||||
folderId: '',
|
||||
},
|
||||
syncTargetAppMinVersionIsRelease: null,
|
||||
didSyncTargetAppMinVersionReleaseLoadFail: false,
|
||||
messageBoxVisible: false,
|
||||
};
|
||||
|
||||
this.updateMainLayout(this.buildLayout(props.plugins));
|
||||
@@ -212,11 +189,6 @@ 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();
|
||||
|
||||
@@ -327,7 +299,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 ||
|
||||
this.messageBoxVisible(prevProps) !== this.messageBoxVisible(this.props)
|
||||
prevState.messageBoxVisible !== this.state.messageBoxVisible
|
||||
) {
|
||||
this.updateRootLayoutSize();
|
||||
}
|
||||
@@ -357,13 +329,6 @@ 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) {
|
||||
@@ -374,7 +339,6 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
||||
public componentDidMount() {
|
||||
window.addEventListener('keydown', this.layoutModeListenerKeyDown);
|
||||
void this.loadSyncTargetAppMinVersionIsRelease();
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
@@ -382,28 +346,6 @@ 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 {
|
||||
@@ -414,7 +356,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
||||
public rowHeight() {
|
||||
if (!this.props) return 0;
|
||||
return this.props.style.height - (this.messageBoxVisible() ? this.messageBoxHeight() : 0);
|
||||
return this.props.style.height - (this.state.messageBoxVisible ? this.messageBoxHeight() : 0);
|
||||
}
|
||||
|
||||
public messageBoxHeight() {
|
||||
@@ -425,8 +367,6 @@ 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_ = {};
|
||||
@@ -435,16 +375,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
width: width,
|
||||
};
|
||||
|
||||
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);
|
||||
const rowHeight = height - (messageBoxVisible ? this.messageBoxHeight() : 0);
|
||||
|
||||
this.styles_.rowHeight = rowHeight;
|
||||
|
||||
@@ -460,254 +391,12 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
return this.styles_;
|
||||
}
|
||||
|
||||
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;
|
||||
public renderNotification() {
|
||||
return <WarningBanner
|
||||
height={this.messageBoxHeight()}
|
||||
onShow={() => this.setState({ messageBoxVisible: true })}
|
||||
onHide={() => this.setState({ messageBoxVisible: false })}
|
||||
/>;
|
||||
}
|
||||
|
||||
private resizableLayout_resize(event: { layout: LayoutItem }) {
|
||||
@@ -772,9 +461,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.messageBoxVisible());
|
||||
const styles = this.styles(this.props.themeId, style.width, style.height, this.state.messageBoxVisible);
|
||||
|
||||
const messageComp = this.renderNotification(theme, styles);
|
||||
const messageComp = this.renderNotification();
|
||||
|
||||
const layoutComp = this.props.mainLayout ? (
|
||||
<ResizableLayout
|
||||
@@ -810,36 +499,20 @@ 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',
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -881,6 +881,7 @@ function useMenu(props: Props) {
|
||||
separator(),
|
||||
menuItemDic.showNoteProperties,
|
||||
menuItemDic.showNoteContentProperties,
|
||||
menuItemDic.revealInNotebook,
|
||||
separator(),
|
||||
menuItemDic.permanentlyDeleteNote,
|
||||
],
|
||||
|
||||
@@ -67,6 +67,7 @@ const mapStateToProps = (state: AppState, connectProps: ConnectProps) => {
|
||||
'textCheckbox',
|
||||
'textHeading',
|
||||
'textHorizontalRule',
|
||||
'textBlockQuote',
|
||||
'editor.textTable',
|
||||
'insertDateTime',
|
||||
'toggleEditors',
|
||||
|
||||
@@ -733,10 +733,10 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
noneditable_class: 'joplin-editable', // Can be a regex too
|
||||
iframe_aria_text: _('Rich Text editor. Press Escape then Tab to escape focus.'),
|
||||
|
||||
// #p: Pad empty paragraphs with to prevent them from being removed.
|
||||
// #p, #h1-#h6: Pad empty paragraphs and headings with to prevent them from being removed.
|
||||
// *[*]: Allow all elements and attributes -- we already filter in sanitize_html
|
||||
// See https://www.tiny.cloud/docs/configure/content-filtering/#controlcharacters
|
||||
valid_elements: '#p,*[*]',
|
||||
valid_elements: '#p,#h1[*],#h2[*],#h3[*],#h4[*],#h5[*],#h6[*],*[*]',
|
||||
|
||||
menubar: false,
|
||||
relative_urls: false,
|
||||
|
||||
@@ -11,7 +11,11 @@ import attachedResources from '@joplin/lib/utils/attachedResources';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { resourceFullPath } from '@joplin/lib/models/utils/resourceUtils';
|
||||
import { ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import { NoteLockNoteEntity } from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
import { FileCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
|
||||
import { resolveCanvasColor } from '@joplin/lib/services/whiteboard/presetColors';
|
||||
import { isInternalRef, RefKind, resolveFileRef } from '@joplin/lib/services/whiteboard/resolveRef';
|
||||
@@ -29,10 +33,14 @@ const resourceUrlFor = (resource: ResourceEntity | null, resourceDirectory: stri
|
||||
return pathToFileURL(resourceFullPath(resource, resourceDirectory)).href;
|
||||
};
|
||||
|
||||
type LockedState = 'sessionLocked' | 'undecryptable' | null;
|
||||
|
||||
interface ResolvedItem {
|
||||
kind: 'note' | 'resource' | 'unknown';
|
||||
title: string;
|
||||
body?: string;
|
||||
isDecrypted?: boolean;
|
||||
lockedState?: LockedState;
|
||||
userUpdatedTime?: number;
|
||||
deletedTime?: number;
|
||||
resource?: ResourceEntity;
|
||||
@@ -43,6 +51,16 @@ const useResolvedRef = (file: string): { resolved: ResolvedItem | null; refetch:
|
||||
const [refetchCount, setRefetchCount] = useState(0);
|
||||
const lastLoadedFileRef = useRef<string | null>(null);
|
||||
|
||||
// Locked cards need to switch between the message and the body when the session changes.
|
||||
useEffect(() => {
|
||||
if (!isNoteLockEnabled()) return undefined;
|
||||
const onSessionChange = () => setRefetchCount(c => c + 1);
|
||||
eventManager.on(EventName.NoteLockSessionChange, onSessionChange);
|
||||
return () => {
|
||||
eventManager.off(EventName.NoteLockSessionChange, onSessionChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const ref = resolveFileRef(file);
|
||||
@@ -64,10 +82,34 @@ const useResolvedRef = (file: string): { resolved: ResolvedItem | null; refetch:
|
||||
return;
|
||||
}
|
||||
if (item.type_ === ModelType.Note) {
|
||||
let body = item.body || '';
|
||||
let isDecrypted = false;
|
||||
let lockedState: LockedState = null;
|
||||
// A locked note's body only decrypts through the gated load; the card never
|
||||
// shows ciphertext and never prompts to unlock the session itself. Unlocked notes
|
||||
// load through the gate as well, so that a checkbox toggle can use a gated save.
|
||||
if (isNoteLockEnabled()) {
|
||||
body = '';
|
||||
if ((item as NoteEntity).is_locked && !NoteLockSession.instance().isUnlocked()) {
|
||||
lockedState = 'sessionLocked';
|
||||
} else {
|
||||
try {
|
||||
const note: NoteLockNoteEntity = await Note.load(ref.id, { useNoteLock: true });
|
||||
body = note.body || '';
|
||||
isDecrypted = !!note.isDecrypted;
|
||||
} catch (error) {
|
||||
logger.warn(`Could not decrypt linked note ${ref.id}:`, error);
|
||||
lockedState = 'undecryptable';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
setResolved({
|
||||
kind: 'note',
|
||||
title: item.title || 'Untitled',
|
||||
body: item.body || '',
|
||||
body,
|
||||
isDecrypted,
|
||||
lockedState,
|
||||
userUpdatedTime: item.user_updated_time,
|
||||
deletedTime: item.deleted_time,
|
||||
});
|
||||
@@ -136,6 +178,7 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
|
||||
const linkedNoteId = resolved?.kind === 'note' ? resolveFileRef(node.file).id : null;
|
||||
const linkedNoteUserUpdatedTime = resolved?.kind === 'note' ? resolved.userUpdatedTime : undefined;
|
||||
const linkedNoteDeletedTime = resolved?.kind === 'note' ? resolved.deletedTime : undefined;
|
||||
const linkedNoteIsDecrypted = resolved?.kind === 'note' ? resolved.isDecrypted : undefined;
|
||||
const savingRef = useRef(false);
|
||||
const onLinkedNoteBodyChange = useCallback(async (newBody: string) => {
|
||||
if (!linkedNoteId) return;
|
||||
@@ -153,9 +196,10 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
|
||||
{
|
||||
id: linkedNoteId,
|
||||
body: newBody,
|
||||
isDecrypted: linkedNoteIsDecrypted,
|
||||
...(linkedNoteUserUpdatedTime ? { user_updated_time: linkedNoteUserUpdatedTime } : {}),
|
||||
},
|
||||
{ changeSource: ItemChange.SOURCE_UNSPECIFIED },
|
||||
} as NoteLockNoteEntity,
|
||||
{ changeSource: ItemChange.SOURCE_UNSPECIFIED, useNoteLock: true },
|
||||
);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
@@ -164,7 +208,7 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
}
|
||||
}, [linkedNoteId, linkedNoteUserUpdatedTime, linkedNoteDeletedTime, refetch]);
|
||||
}, [linkedNoteId, linkedNoteUserUpdatedTime, linkedNoteDeletedTime, linkedNoteIsDecrypted, refetch]);
|
||||
const checkboxRef = useCheckboxToggle({
|
||||
body: resolved?.kind === 'note' ? (resolved.body ?? '') : '',
|
||||
onChange: onLinkedNoteBodyChange,
|
||||
@@ -178,6 +222,17 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
|
||||
}
|
||||
|
||||
if (resolved?.kind === 'note' && !isInTrash) {
|
||||
if (resolved.lockedState) {
|
||||
const message = resolved.lockedState === 'sessionLocked'
|
||||
? _('This linked item cannot be displayed because the note is locked. Unlock the session to view it')
|
||||
: _('This linked item cannot be displayed because the note is locked and could not be unlocked');
|
||||
return (
|
||||
<>
|
||||
<div className="header -note-title" title={resolved.title}>{resolved.title}</div>
|
||||
<div className="body -locked">{message}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="header -note-title" title={resolved.title}>{resolved.title}</div>
|
||||
|
||||
@@ -236,7 +236,7 @@ $hover-bg: rgba(127, 127, 127, 0.12);
|
||||
line-height: 1.4;
|
||||
color: var(--joplin-color);
|
||||
|
||||
&.-deleted { color: var(--joplin-color-faded); font-style: italic; }
|
||||
&.-deleted, &.-locked { color: var(--joplin-color-faded); font-style: italic; }
|
||||
&.-url { word-break: break-all; }
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,10 @@ import markupLanguageUtils from '@joplin/lib/utils/markupLanguageUtils';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
import { itemIsReadOnly } from '@joplin/lib/models/utils/readOnly';
|
||||
import { itemIsReadOnly, noteIsLockedInShare } from '@joplin/lib/models/utils/readOnly';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import hasNoteLockKey from '../utils/hasNoteLockKey';
|
||||
import NoteLockPanel from './NoteLockPanel/NoteLockPanel';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { substrWithEllipsis } from '@joplin/lib/string-utils';
|
||||
@@ -84,7 +84,7 @@ let editorIdCounter = 0;
|
||||
function NoteEditorContent(props: NoteEditorProps) {
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
|
||||
const [isReadOnly, setIsReadOnly] = useState<boolean>(false);
|
||||
const [isReadOnlyItem, setIsReadOnlyItem] = useState<boolean>(false);
|
||||
const [reloadInProgress, setReloadInProgress] = useState(false);
|
||||
|
||||
const editorRef = useRef<NoteBodyEditorRef|null>(null);
|
||||
@@ -178,7 +178,8 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
// Enabling requires an unlocked session, so capture the key the same way a decrypt
|
||||
// does - a pending save can then still encrypt if the session locks before it runs.
|
||||
const noteLockKey = event.isLocked && NoteLockSession.instance().isUnlocked() ? NoteLockSession.instance().decryptedKey() : null;
|
||||
const newFormNote = { ...formNoteRef.current, is_locked: event.isLocked ? 1 : 0, noteLockKey, isDecrypted: event.isLocked };
|
||||
// The form note came from a gated load, so the marker stays true even when disabling.
|
||||
const newFormNote = { ...formNoteRef.current, is_locked: event.isLocked ? 1 : 0, noteLockKey, isDecrypted: true };
|
||||
setFormNote(newFormNote);
|
||||
void scheduleSaveNote(newFormNote);
|
||||
};
|
||||
@@ -375,13 +376,17 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
return parseShareCache(props.shareCacheSetting);
|
||||
}, [props.shareCacheSetting]);
|
||||
|
||||
// The list row covers a form whose refresh was skipped for unsaved edits, the form covers a window whose list no longer holds the note.
|
||||
const lockedInShare = noteIsLockedInShare(formNote) || noteIsLockedInShare(props.notes.find(n => n.id === formNote.id));
|
||||
const isReadOnly = isReadOnlyItem || lockedInShare;
|
||||
|
||||
useAsyncEffect(async event => {
|
||||
if (!formNote.id) return;
|
||||
|
||||
try {
|
||||
const result = await itemIsReadOnly(BaseItem, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, formNote.id, props.syncUserId, shareCache);
|
||||
if (event.cancelled) return;
|
||||
setIsReadOnly(result);
|
||||
setIsReadOnlyItem(result);
|
||||
} catch (error) {
|
||||
if (error.code === ErrorCode.NotFound) {
|
||||
// Can happen if the note has been deleted but a render is
|
||||
@@ -390,7 +395,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}, [formNote.id, props.syncUserId, shareCache]);
|
||||
}, [formNote.id, formNote.deleted_time, props.syncUserId, shareCache]);
|
||||
|
||||
const onBodyWillChange = useCallback((event: { changeId: number }) => {
|
||||
handleProvisionalFlag();
|
||||
@@ -634,7 +639,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
|
||||
return (
|
||||
<div style={revStyle} ref={containerRef}>
|
||||
<NoteRevisionViewer customCss={props.customCss} noteId={formNote.id} onBack={noteRevisionViewer_onBack} />
|
||||
<NoteRevisionViewer customCss={props.customCss} noteId={isNoteLockEnabled() ? effectiveNoteId : formNote.id} onBack={noteRevisionViewer_onBack} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -766,6 +771,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
hasNoteLockKey={props.hasNoteLockKey}
|
||||
dispatch={props.dispatch}
|
||||
undecryptable={true}
|
||||
lockedInShare={noteIsLockedInShare(lockedNoteMetadata)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -781,6 +787,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
noteTitle={lockedNoteMetadata.title}
|
||||
hasNoteLockKey={props.hasNoteLockKey}
|
||||
dispatch={props.dispatch}
|
||||
lockedInShare={noteIsLockedInShare(lockedNoteMetadata)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -843,7 +850,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
setTagsToolbarButtonInfo={props.setTagsToolbarButtonInfo}
|
||||
selectedNoteTags={props.selectedNoteTags}
|
||||
/>
|
||||
<WarningBanner bodyEditor={props.bodyEditor}/>
|
||||
<WarningBanner bodyEditor={props.bodyEditor} lockedInShare={lockedInShare}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -853,15 +860,6 @@ interface ConnectProps {
|
||||
windowId: string;
|
||||
}
|
||||
|
||||
// Memoized because mapStateToProps runs on every dispatch and SyncInfo parses the cached JSON.
|
||||
let hasNoteLockKeyCache: { syncInfoCache: string; value: boolean } = null;
|
||||
const hasNoteLockKey = (syncInfoCache: string) => {
|
||||
if (!hasNoteLockKeyCache || hasNoteLockKeyCache.syncInfoCache !== syncInfoCache) {
|
||||
hasNoteLockKeyCache = { syncInfoCache, value: !!new SyncInfo(syncInfoCache).noteLockKey };
|
||||
}
|
||||
return hasNoteLockKeyCache.value;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
|
||||
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
hasNoteLockKey: boolean;
|
||||
dispatch: Dispatch;
|
||||
undecryptable?: boolean;
|
||||
lockedInShare?: boolean;
|
||||
}
|
||||
|
||||
export default function NoteLockPanel(props: Props) {
|
||||
@@ -50,7 +51,7 @@ export default function NoteLockPanel(props: Props) {
|
||||
|
||||
const renderAction = () => {
|
||||
if (props.undecryptable) {
|
||||
return <p className="message">{_('This note could not be decrypted. If it was encrypted prior to a password reset, the contents are no longer recoverable.')}</p>;
|
||||
return <p className="message">{_('This note could not be unlocked. If it was locked prior to a password reset, the content is no longer recoverable.')}</p>;
|
||||
}
|
||||
|
||||
if (!props.hasNoteLockKey) {
|
||||
@@ -64,7 +65,7 @@ export default function NoteLockPanel(props: Props) {
|
||||
|
||||
return (
|
||||
<form className="form unlock-form" onSubmit={onSubmit}>
|
||||
<p className="message">{_('This note is encrypted. Enter the note lock password to unlock encrypted notes for this session.')}</p>
|
||||
<p className="message">{_('This note is locked. Enter your password to unlock your notes for this session.')}</p>
|
||||
<div className="field">
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Note lock password')}
|
||||
@@ -79,10 +80,11 @@ export default function NoteLockPanel(props: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="note-lock-panel">
|
||||
<div className={`note-lock-panel ${props.lockedInShare ? '-in-share' : ''}`}>
|
||||
<i className="icon fas fa-lock" role="img" aria-label={_('Locked note')}></i>
|
||||
<h2 className="title">{props.noteTitle}</h2>
|
||||
{renderAction()}
|
||||
{props.lockedInShare ? <div className="warning-banner banner">{_('This note may not be readable because it is contained within a share.')}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,3 +45,18 @@
|
||||
color: var(--joplin-color-error);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.note-lock-panel.-in-share {
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.note-lock-panel.-in-share > .icon {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.note-lock-panel > .banner {
|
||||
align-self: stretch;
|
||||
margin: auto calc(-1 * var(--joplin-main-padding)) 5px;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
acceptMessage: string;
|
||||
onAccept: ()=> void;
|
||||
acceptMessage?: string;
|
||||
onAccept?: ()=> void;
|
||||
onDismiss?: ()=> void;
|
||||
dismissMessage?: string;
|
||||
visible: boolean;
|
||||
@@ -17,7 +17,7 @@ const BannerContent: React.FC<Props> = props => {
|
||||
|
||||
return <div className='warning-banner'>
|
||||
{props.children}
|
||||
<a onClick={props.onAccept} className='warning-banner-link' href="#">[ {props.acceptMessage} ]</a>
|
||||
{ props.acceptMessage ? <> <a onClick={props.onAccept} className='warning-banner-link' href="#">[ {props.acceptMessage} ]</a></> : null }
|
||||
{ props.onDismiss ? <a onClick={props.onDismiss} className='warning-banner-link' href="#">[ {props.dismissMessage ?? _('Dismiss')} ]</a> : null }
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NoteBodyEditorType } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
bodyEditor: NoteBodyEditorType;
|
||||
lockedInShare: boolean;
|
||||
editorMigrationVersion: number;
|
||||
richTextBannerDismissed: boolean;
|
||||
inEditorRenderingEnabled: boolean;
|
||||
@@ -55,6 +56,12 @@ const WarningBanner: React.FC<Props> = props => {
|
||||
>{editorMigrationMessage.label}</BannerContent>
|
||||
);
|
||||
|
||||
const lockedInShareBanner = (
|
||||
<BannerContent visible={props.lockedInShare}>
|
||||
{_('This note is read-only because it is locked and contained within a share. To enable editing, it must be moved outside of the share.')}
|
||||
</BannerContent>
|
||||
);
|
||||
|
||||
const wysiwygBanner = (
|
||||
<BannerContent
|
||||
acceptMessage={_('Read more about it')}
|
||||
@@ -101,6 +108,7 @@ const WarningBanner: React.FC<Props> = props => {
|
||||
);
|
||||
|
||||
return <>
|
||||
{lockedInShareBanner}
|
||||
{wysiwygBanner}
|
||||
{markdownPluginBanner}
|
||||
{editorMigrationBanner}
|
||||
|
||||
@@ -107,6 +107,11 @@ const declarations: CommandDeclaration[] = [
|
||||
label: () => _('Checkbox'),
|
||||
iconName: 'icon-to-do-list',
|
||||
},
|
||||
{
|
||||
name: 'textBlockQuote',
|
||||
label: () => _('Blockquote'),
|
||||
iconName: 'icon-quote',
|
||||
},
|
||||
{
|
||||
name: 'textHeading',
|
||||
label: () => _('Heading'),
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
|
||||
@use "./styles/warning-banner.scss";
|
||||
@use "./styles/warning-banner-link.scss";
|
||||
@use "./styles/note-title-info-group.scss";
|
||||
@use "./styles/note-title-wrapper.scss";
|
||||
@use "./styles/note-editor-wrapper.scss";
|
||||
|
||||
@@ -167,6 +167,7 @@ export interface FormNote {
|
||||
title: string;
|
||||
body: string;
|
||||
parent_id: string;
|
||||
share_id: string;
|
||||
is_todo: number;
|
||||
is_conflict?: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Editor-specific content shape (e.g. TinyMCE retains the raw editor object here); per-editor type
|
||||
@@ -220,6 +221,7 @@ export function defaultFormNote(): FormNote {
|
||||
return {
|
||||
id: '',
|
||||
parent_id: '',
|
||||
share_id: '',
|
||||
deleted_time: 0,
|
||||
title: '',
|
||||
body: '',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { setupDatabaseAndSynchronizer, supportDir, switchClient, withWarningSilenced, noteLockCipherTextStandIn } from '@joplin/lib/testing/test-utils';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import useFormNote, { HookDependencies } from './useFormNote';
|
||||
import shim from '@joplin/lib/shim';
|
||||
@@ -36,14 +36,17 @@ describe('useFormNote', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
// Pinned off regardless of the build's default so the fixtures below save raw bodies.
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
});
|
||||
|
||||
// The session and decryption internals are covered by the lib tests; here they are mocked to
|
||||
// test only the hook's gating: ciphertext must never reach the form note.
|
||||
it('should not produce a form note for a locked note while the session is locked, and decrypt it once unlocked', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
// A direct save of a new note with is_locked keeps the raw body, standing in for ciphertext.
|
||||
// Saved with the flag off so the raw body stands in for ciphertext; an ungated save with
|
||||
// the flag on would encrypt it.
|
||||
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
|
||||
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(false);
|
||||
const decryptedKeyMock = jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
|
||||
@@ -101,8 +104,8 @@ describe('useFormNote', () => {
|
||||
});
|
||||
|
||||
it('should report a decryption failure instead of throwing, without producing a form note', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
// The save's own change event would otherwise reach the hook's listener and reload the note.
|
||||
await ItemChange.waitForAllSaved();
|
||||
|
||||
@@ -114,9 +117,11 @@ describe('useFormNote', () => {
|
||||
const render = renderHook(props => useFormNote(props), {
|
||||
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, noteLockSessionUnlocked: true, onDecryptFailedChange },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(render.result.current.decryptFailed).toBe(true);
|
||||
});
|
||||
await withWarningSilenced(/Could not decrypt locked note/, async () => {
|
||||
await waitFor(() => {
|
||||
expect(render.result.current.decryptFailed).toBe(true);
|
||||
});
|
||||
}, { requireWarning: true });
|
||||
expect(render.result.current.formNote).toMatchObject({
|
||||
id: '',
|
||||
body: '',
|
||||
@@ -188,6 +193,43 @@ describe('useFormNote', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should show a locked note again after sync re-encrypts it and the decryption worker restores it', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const testNote = await Note.save({ title: 'Locked', body: noteLockCipherTextStandIn(), is_locked: 1 });
|
||||
jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
|
||||
jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
|
||||
// Like the real decrypt, a row sync has not decrypted yet has no note lock body and fails.
|
||||
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => {
|
||||
if (note.encryption_applied) throw new Error('Invalid encryption identifier');
|
||||
return { ...note, isDecrypted: true, body: 'secret' };
|
||||
});
|
||||
const onReloadInProgressChange = jest.fn();
|
||||
const props = { ...defaultFormNoteProps, noteId: testNote.id, noteLockSessionUnlocked: true, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
|
||||
|
||||
try {
|
||||
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
|
||||
await waitFor(() => expect(formNote.result.current.formNote.body).toBe('secret'));
|
||||
|
||||
// Sync overwrites the row with the encrypted item and asks the editor to reload.
|
||||
await act(async () => {
|
||||
await Note.save({ id: testNote.id, encryption_cipher_text: 'cipher_text', encryption_applied: 1, body: '', is_locked: 1 });
|
||||
});
|
||||
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
|
||||
await waitFor(() => expect(formNote.result.current.formNote).toMatchObject({ encryption_applied: 1 }));
|
||||
|
||||
// The decryption worker then writes the decrypted item back.
|
||||
await act(async () => {
|
||||
await Note.save({ id: testNote.id, title: 'Locked', body: noteLockCipherTextStandIn(), is_locked: 1, encryption_cipher_text: '', encryption_applied: 0 }, { autoTimestamp: false, changeSource: ItemChange.SOURCE_DECRYPTION });
|
||||
});
|
||||
await waitFor(() => expect(formNote.result.current.formNote).toMatchObject({ encryption_applied: 0, body: 'secret' }), { timeout: 15_000 });
|
||||
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
|
||||
formNote.unmount();
|
||||
} finally {
|
||||
decryptBodyMock.mockRestore();
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Lacking is_conflict has previously caused UI issues. See https://github.com/laurent22/joplin/pull/10913
|
||||
// for details.
|
||||
it('should preserve value of is_conflict on save', async () => {
|
||||
@@ -237,15 +279,17 @@ describe('useFormNote', () => {
|
||||
await waitFor(() => {
|
||||
expect(formNote.result.current.formNote.title).toBe('Test Note!');
|
||||
});
|
||||
expect(formNote.result.current.formNote.share_id).toBe('');
|
||||
|
||||
// Simulate the note being modified outside the editor
|
||||
// Simulate the note being modified outside the editor, e.g. sync assigning it to a share
|
||||
await act(async () => {
|
||||
await Note.save({ id: note.id, title: 'Modified' });
|
||||
await Note.save({ id: note.id, title: 'Modified', share_id: 'share-1' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(formNote.result.current.formNote.title).toBe('Modified');
|
||||
});
|
||||
expect(formNote.result.current.formNote.share_id).toBe('share-1');
|
||||
expect(onReloadInProgressChange).not.toHaveBeenCalled();
|
||||
|
||||
formNote.unmount();
|
||||
|
||||
@@ -90,7 +90,10 @@ const loadNoteForForm = async (noteId: string): Promise<{ note: NoteEntity|null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { note: await Note.load(noteId), blocked: false, decryptFailed: false };
|
||||
// An unlocked note still goes through the gate, so the form note carries the marker
|
||||
// that its gated scheduled saves require.
|
||||
const note = await Note.load(noteId, { useNoteLock: true });
|
||||
return { note, blocked: false, decryptFailed: false };
|
||||
};
|
||||
|
||||
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean, force?: boolean)=> Promise<FormNote>;
|
||||
@@ -258,6 +261,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
body: n.body,
|
||||
is_todo: n.is_todo,
|
||||
parent_id: n.parent_id,
|
||||
share_id: n.share_id,
|
||||
deleted_time: n.deleted_time,
|
||||
is_conflict: n.is_conflict,
|
||||
bodyWillChangeId: 0,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import { noteIsLockedInShare } from '@joplin/lib/models/utils/readOnly';
|
||||
import bridge from '../../services/bridge';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useRenderedNote from './utils/useRenderedNote';
|
||||
@@ -63,8 +64,13 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
};
|
||||
|
||||
if (changeEvent.elementId === 'todo-checkbox') {
|
||||
const lockState = await Note.load(changeEvent.noteId, { fields: ['is_locked', 'share_id'] });
|
||||
if (noteIsLockedInShare(lockState)) {
|
||||
event.target.checked = !changeEvent.value;
|
||||
bridge().showErrorMessageBox(_('This note is read-only because it is locked and contained within a share. To enable editing, it must be moved outside of the share.'));
|
||||
return;
|
||||
}
|
||||
if (isNoteLockEnabled()) {
|
||||
const lockState = await Note.load(changeEvent.noteId, { fields: ['is_locked'] });
|
||||
if (NoteLockNote.isLocked(lockState) && !NoteLockSession.instance().isUnlocked()) {
|
||||
// event.currentTarget is already cleared here, after the await - target is the same input
|
||||
event.target.checked = !changeEvent.value;
|
||||
|
||||
@@ -63,9 +63,9 @@ export default function(props: Props) {
|
||||
return (
|
||||
<Dialog onCancel={onClose} className="note-lock-unlock-dialog">
|
||||
<div className="dialog-root">
|
||||
<DialogTitle title={_('Unlock encrypted notes')}/>
|
||||
<DialogTitle title={_('Unlock notes')}/>
|
||||
<div className="dialog-content">
|
||||
<p>{_('Enter the note lock password to unlock encrypted notes for this session.')}</p>
|
||||
<p>{_('Enter your password to unlock your notes for this session.')}</p>
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Note lock password')}
|
||||
value={password}
|
||||
|
||||
@@ -27,6 +27,11 @@ import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
|
||||
import { getGlobalSettings } from '@joplin/renderer/types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockService from '@joplin/lib/services/noteLock/NoteLockService';
|
||||
import NoteLockPanel from './NoteEditor/NoteLockPanel/NoteLockPanel';
|
||||
import hasNoteLockKey from './utils/hasNoteLockKey';
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -36,6 +41,9 @@ interface Props {
|
||||
scrollbarSize: ScrollbarSize;
|
||||
fontFamily: string;
|
||||
showNoteLinkIcon: boolean;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
hasNoteLockKey: boolean;
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
const useNoteContent = (
|
||||
@@ -47,8 +55,13 @@ const useNoteContent = (
|
||||
scrollbarSize: ScrollbarSize,
|
||||
fontFamily: string,
|
||||
showNoteLinkIcon: boolean,
|
||||
canDecrypt: boolean,
|
||||
) => {
|
||||
const [note, setNote] = useState<NoteEntity>(null);
|
||||
// The revision note as merged from the diffs, with a locked body still encrypted. Restoring
|
||||
// this keeps the restored copy a valid locked note, so it is never decrypted for restore.
|
||||
const [restoreNote, setRestoreNote] = useState<NoteEntity>(null);
|
||||
const [decryptFailed, setDecryptFailed] = useState(false);
|
||||
|
||||
const markupToHtml = useMarkupToHtml({
|
||||
themeId,
|
||||
@@ -60,15 +73,37 @@ const useNoteContent = (
|
||||
});
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
// Cleared before the load so a switch never keeps the previous revision's failure or restore target.
|
||||
setRestoreNote(null);
|
||||
setDecryptFailed(false);
|
||||
if (!revisions.length || !currentRevId) {
|
||||
setNote(null);
|
||||
} else {
|
||||
const revIndex = BaseModel.modelIndexById(revisions, currentRevId);
|
||||
const note = await RevisionService.instance().revisionNote(revisions, revIndex);
|
||||
if (!note || event.cancelled) return;
|
||||
setNote(note);
|
||||
if (isNoteLockEnabled() && revisions[revIndex].is_locked) {
|
||||
// A revision is a JSON diff object, so a gated load is not possible: the diffs are
|
||||
// merged first and the resulting body is decrypted manually here.
|
||||
let displayBody = '';
|
||||
let failed = false;
|
||||
if (canDecrypt) {
|
||||
try {
|
||||
displayBody = await NoteLockService.instance().decryptString(note.body ?? '');
|
||||
} catch (error) {
|
||||
console.warn('Could not decrypt revision content:', error);
|
||||
failed = true;
|
||||
}
|
||||
if (event.cancelled) return;
|
||||
}
|
||||
setDecryptFailed(failed);
|
||||
setNote({ ...note, body: displayBody });
|
||||
} else {
|
||||
setNote(note);
|
||||
}
|
||||
setRestoreNote(note);
|
||||
}
|
||||
}, [revisions, currentRevId, themeId, customCss, viewerRef]);
|
||||
}, [revisions, currentRevId, themeId, customCss, viewerRef, canDecrypt]);
|
||||
|
||||
useQueuedAsyncEffect(async () => {
|
||||
const noteBody = note?.body ?? _('This note has no history');
|
||||
@@ -85,10 +120,10 @@ const useNoteContent = (
|
||||
});
|
||||
}, [note, viewerRef, markupToHtml, showNoteLinkIcon]);
|
||||
|
||||
return note;
|
||||
return { note, restoreNote, decryptFailed };
|
||||
};
|
||||
|
||||
const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily, showNoteLinkIcon }) => {
|
||||
const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily, showNoteLinkIcon, noteLockSessionUnlocked, hasNoteLockKey, dispatch }) => {
|
||||
const helpButton_onClick = useCallback(() => {}, []);
|
||||
const viewerRef = useRef<NoteViewerControl|null>(null);
|
||||
const revisionListRef = useRef<HTMLSelectElement|null>(null);
|
||||
@@ -98,8 +133,9 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const note = useNoteContent(
|
||||
viewerRef, currentRevId, revisions, themeId, customCss, scrollbarSize, fontFamily, showNoteLinkIcon,
|
||||
const canDecrypt = noteLockSessionUnlocked && hasNoteLockKey;
|
||||
const { note, restoreNote, decryptFailed } = useNoteContent(
|
||||
viewerRef, currentRevId, revisions, themeId, customCss, scrollbarSize, fontFamily, showNoteLinkIcon, canDecrypt,
|
||||
);
|
||||
|
||||
const viewer_domReady = useCallback(async () => {
|
||||
@@ -112,12 +148,12 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
|
||||
}, [noteId]);
|
||||
|
||||
const importButton_onClick = useCallback(async () => {
|
||||
if (!note) return;
|
||||
if (!restoreNote) return;
|
||||
setRestoring(true);
|
||||
await RevisionService.instance().importRevisionNote(note);
|
||||
await RevisionService.instance().importRevisionNote(restoreNote);
|
||||
setRestoring(false);
|
||||
await shim.showMessageBox(RevisionService.instance().restoreSuccessMessage(note), { type: MessageBoxType.Info });
|
||||
}, [note]);
|
||||
await shim.showMessageBox(RevisionService.instance().restoreSuccessMessage(restoreNote), { type: MessageBoxType.Info });
|
||||
}, [restoreNote]);
|
||||
|
||||
const resetScreenState = useCallback(() => {
|
||||
setRevisions([]);
|
||||
@@ -188,6 +224,9 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
|
||||
);
|
||||
}
|
||||
|
||||
const revisionLocked = isNoteLockEnabled() && revisions.some(r => r.id === currentRevId && !!r.is_locked);
|
||||
const showLockPanel = revisionLocked && (!canDecrypt || decryptFailed);
|
||||
|
||||
const restoreButtonTitle = _('Restore');
|
||||
const deleteHistoryButtonTitle = _('Delete history');
|
||||
const helpMessage = getHelpMessage(restoreButtonTitle);
|
||||
@@ -201,7 +240,7 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
|
||||
<select disabled={!revisions.length} value={currentRevId} className='revisions' style={theme.dropdownList} onChange={revisionList_onChange} ref={revisionListRef}>
|
||||
{revisionListItems}
|
||||
</select>
|
||||
<button disabled={!revisions.length || restoring} onClick={importButton_onClick} className='restore'style={{ ...theme.buttonStyle, marginLeft: 10, height: theme.inputStyle.height }}>
|
||||
<button disabled={!restoreNote || restoring || showLockPanel} onClick={importButton_onClick} className='restore'style={{ ...theme.buttonStyle, marginLeft: 10, height: theme.inputStyle.height }}>
|
||||
{restoreButtonTitle}
|
||||
</button>
|
||||
<button disabled={!revisions.length || deleting} onClick={deleteHistoryButton_onClick} className='deleteHistory'style={{ ...theme.buttonStyle, marginLeft: 10, height: theme.inputStyle.height }}>
|
||||
@@ -211,7 +250,8 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
|
||||
</div>
|
||||
);
|
||||
|
||||
const viewer = <NoteTextViewer themeId={themeId} viewerStyle={{ display: 'flex', flex: 1, borderLeft: 'none' }} ref={viewerRef} onDomReady={viewer_domReady} onIpcMessage={webview_ipcMessage} />;
|
||||
// The viewer stays mounted while the lock panel shows because its dom-ready event loads the revision list.
|
||||
const viewer = <NoteTextViewer themeId={themeId} viewerStyle={{ display: showLockPanel ? 'none' : 'flex', flex: 1, borderLeft: 'none' }} ref={viewerRef} onDomReady={viewer_domReady} onIpcMessage={webview_ipcMessage} />;
|
||||
|
||||
useEffect(() => {
|
||||
// We need to force focus here because otherwise the focus is lost and goes back
|
||||
@@ -222,6 +262,12 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
|
||||
return (
|
||||
<div className='revision-viewer-root'>
|
||||
{titleInput}
|
||||
{showLockPanel ? <NoteLockPanel
|
||||
noteTitle={note?.title ?? ''}
|
||||
hasNoteLockKey={hasNoteLockKey}
|
||||
dispatch={dispatch}
|
||||
undecryptable={decryptFailed && noteLockSessionUnlocked}
|
||||
/> : null}
|
||||
{viewer}
|
||||
<ReactTooltip place="bottom" delayShow={300} className="help-tooltip" />
|
||||
</div>
|
||||
@@ -234,6 +280,8 @@ const mapStateToProps = (state: AppState) => {
|
||||
scrollbarSize: state.settings['style.scrollbarSize'],
|
||||
fontFamily: state.settings['style.viewer.fontFamily'],
|
||||
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
|
||||
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
|
||||
hasNoteLockKey: hasNoteLockKey(state.settings['syncInfoCache']),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import bridge from '../services/bridge';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { OneDriveApiNodeUtils } from '@joplin/lib/onedrive-api-node-utils';
|
||||
import type SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -43,7 +44,7 @@ class OneDriveLoginScreenComponent extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
const syncTargetId = Setting.value('sync.target');
|
||||
const syncTarget = reg.syncTarget(syncTargetId);
|
||||
const syncTarget = reg.syncTarget(syncTargetId) as SyncTargetOneDrive;
|
||||
const oneDriveApiUtils = new OneDriveApiNodeUtils(syncTarget.api());
|
||||
const auth = await oneDriveApiUtils.oauthDance({
|
||||
log: (s: string) => log(s),
|
||||
@@ -60,11 +61,11 @@ class OneDriveLoginScreenComponent extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
public startUrl() {
|
||||
return reg.syncTarget().api().authCodeUrl(this.redirectUrl());
|
||||
return (reg.syncTarget() as SyncTargetOneDrive).api().authCodeUrl(this.redirectUrl());
|
||||
}
|
||||
|
||||
public redirectUrl() {
|
||||
return reg.syncTarget().api().nativeClientRedirectUrl();
|
||||
return (reg.syncTarget() as SyncTargetOneDrive).api().nativeClientRedirectUrl();
|
||||
}
|
||||
|
||||
public render() {
|
||||
|
||||
@@ -98,7 +98,7 @@ export function PublishFolderDialog(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
{publishedShare && (
|
||||
<Button disabled={isUnpublishing} tooltip={_('Unpublish notebook')} iconName="fas fa-share-alt" onClick={onUnpublishFolderClick}/>
|
||||
<Button disabled={isUnpublishing} tooltip={_('Unpublish notebook')} iconName="fas fa-unlink" onClick={onUnpublishFolderClick}/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -134,7 +134,7 @@ const NoteItem: React.FC<NoteItemProps> = ({ note, shares }) => {
|
||||
const published = useIsPublished(note, shares);
|
||||
|
||||
const unshareButton = published && (
|
||||
<Button tooltip={_('Unpublish note')} iconName='fas fa-share-alt' onClick={() => onUnshareNoteClick({ noteId: note.id })}/>
|
||||
<Button tooltip={_('Unpublish note')} iconName='fas fa-unlink' onClick={() => onUnshareNoteClick({ noteId: note.id })}/>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import * as React from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import checkForUpdates, { isReleaseVersion } from '../checkForUpdates';
|
||||
import bridge from '../services/bridge';
|
||||
import restart from '../services/restart';
|
||||
import invitationRespond from '@joplin/lib/services/share/invitationRespond';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { AppState } from '../app.reducer';
|
||||
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
|
||||
import { showMissingMasterKeyMessage, showUnknownKeyFormatBanner } from '@joplin/lib/services/e2ee/utils';
|
||||
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
const logger = Logger.create('WarningBanner');
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
isSafeMode: boolean;
|
||||
hasMissingSyncCredentials: boolean;
|
||||
shouldUpgradeSyncTarget: boolean;
|
||||
hasDisabledEncryptionItems: boolean;
|
||||
showNeedUpgradingMasterKeyMessage: boolean;
|
||||
showShouldReencryptMessage: boolean;
|
||||
processingShareInvitationResponse: boolean;
|
||||
shareInvitations: ShareInvitation[];
|
||||
hasDisabledSyncItems: boolean;
|
||||
showUnknownKeyFormatMessage: boolean;
|
||||
showMissingMasterKeyMessage: boolean;
|
||||
mustUpgradeAppMessage: string;
|
||||
syncTargetAppMinVersion: string;
|
||||
shouldSwitchToAppleSiliconVersion: boolean;
|
||||
showInvalidJoplinCloudCredential: boolean;
|
||||
onShow: ()=> void;
|
||||
onHide: ()=> void;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const WarningBanner: React.FC<Props> = props => {
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
|
||||
const renderNotificationMessage = (
|
||||
message: string, callForAction: string = null, callForActionHandler: ()=> void = null, callForAction2: string = null, callForActionHandler2: ()=> void = null,
|
||||
) => {
|
||||
return <NotificationMessage
|
||||
message={message}
|
||||
callForAction={callForAction}
|
||||
callForActionHandler={callForActionHandler}
|
||||
callForAction2={callForAction2}
|
||||
callForActionHandler2={callForActionHandler2}
|
||||
/>;
|
||||
};
|
||||
|
||||
const onViewStatusScreen = () => {
|
||||
props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Status',
|
||||
});
|
||||
};
|
||||
|
||||
const onViewEncryptionConfigScreen = () => {
|
||||
props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Config',
|
||||
props: {
|
||||
defaultSection: 'encryption',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onViewJoplinCloudLoginScreen = () => {
|
||||
props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'JoplinCloudLogin',
|
||||
});
|
||||
};
|
||||
|
||||
const onDisableSync = () => {
|
||||
Setting.setValue('sync.target', null);
|
||||
};
|
||||
|
||||
const onViewSyncSettingsScreen = () => {
|
||||
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);
|
||||
};
|
||||
|
||||
const showShareInvitationNotification = () => {
|
||||
if (props.processingShareInvitationResponse) return false;
|
||||
return !!props.shareInvitations.find(i => i.status === 0);
|
||||
};
|
||||
|
||||
const appMinVersionState = useAppMinVersionState({ appMinVersion: props.syncTargetAppMinVersion, mustUpgradeAppMessage: props.mustUpgradeAppMessage });
|
||||
|
||||
let msg = null;
|
||||
|
||||
if (props.isSafeMode) {
|
||||
msg = renderNotificationMessage(
|
||||
_('Safe mode is currently active. Note rendering and all plugins are temporarily disabled.'),
|
||||
_('Disable safe mode and restart'),
|
||||
onDisableSafeModeAndRestart,
|
||||
);
|
||||
} else if (props.hasMissingSyncCredentials) {
|
||||
msg = renderNotificationMessage(
|
||||
_('The synchronisation password is missing.'),
|
||||
_('Set the password'),
|
||||
onViewSyncSettingsScreen,
|
||||
);
|
||||
} else if (props.shouldUpgradeSyncTarget) {
|
||||
msg = 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 (props.hasDisabledEncryptionItems) {
|
||||
msg = renderNotificationMessage(
|
||||
_('Some items cannot be decrypted.'),
|
||||
_('View them now'),
|
||||
onViewStatusScreen,
|
||||
);
|
||||
} else if (props.showNeedUpgradingMasterKeyMessage) {
|
||||
msg = renderNotificationMessage(
|
||||
_('One of your master keys use an obsolete encryption method.'),
|
||||
_('View them now'),
|
||||
onViewEncryptionConfigScreen,
|
||||
);
|
||||
} else if (props.showShouldReencryptMessage) {
|
||||
msg = renderNotificationMessage(
|
||||
_('The default encryption method has been changed, you should re-encrypt your data.'),
|
||||
_('More info'),
|
||||
onViewEncryptionConfigScreen,
|
||||
);
|
||||
} else if (showShareInvitationNotification()) {
|
||||
const invitation = props.shareInvitations.find(inv => inv.status === 0);
|
||||
const sharer = invitation.share.user;
|
||||
|
||||
msg = 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 (props.hasDisabledSyncItems) {
|
||||
msg = renderNotificationMessage(
|
||||
_('Some items cannot be synchronised.'),
|
||||
_('View them now'),
|
||||
onViewStatusScreen,
|
||||
);
|
||||
} else if (props.showMissingMasterKeyMessage) {
|
||||
msg = renderNotificationMessage(
|
||||
_('One or more master keys need a password.'),
|
||||
_('Set the password'),
|
||||
onViewEncryptionConfigScreen,
|
||||
);
|
||||
} else if (props.mustUpgradeAppMessage) {
|
||||
if (!props.syncTargetAppMinVersion) {
|
||||
msg = renderNotificationMessage(props.mustUpgradeAppMessage);
|
||||
} else if (appMinVersionState.didReleaseLoadFail) {
|
||||
msg = renderNotificationMessage(
|
||||
_(
|
||||
'In order to synchronise, Please upgrade your application to version %s. Joplin could not check update information.',
|
||||
props.syncTargetAppMinVersion,
|
||||
),
|
||||
);
|
||||
} else if (appMinVersionState.isRelease === false && shim.isLinux()) {
|
||||
const callForAction = _('Download it from GitHub Releases');
|
||||
msg = renderNotificationMessage(
|
||||
_(
|
||||
'In order to synchronise, Please upgrade your application to version %s: %s or update it using your package manager',
|
||||
props.syncTargetAppMinVersion,
|
||||
callForAction,
|
||||
),
|
||||
callForAction,
|
||||
() => shim.openUrl('https://github.com/laurent22/joplin/releases'),
|
||||
);
|
||||
} else if (appMinVersionState.isRelease !== null) {
|
||||
const isTargetPreRelease = appMinVersionState.isRelease === false;
|
||||
const callForAction = isTargetPreRelease ? _('Download it from GitHub Releases') : _('Check for updates');
|
||||
msg = renderNotificationMessage(
|
||||
_(
|
||||
'In order to synchronise, Please upgrade your application to version %s: %s',
|
||||
props.syncTargetAppMinVersion,
|
||||
callForAction,
|
||||
),
|
||||
callForAction,
|
||||
isTargetPreRelease ? () => shim.openUrl('https://github.com/laurent22/joplin/releases') : onCheckForUpdates,
|
||||
);
|
||||
} else {
|
||||
msg = renderNotificationMessage(props.mustUpgradeAppMessage);
|
||||
}
|
||||
} else if (props.showUnknownKeyFormatMessage) {
|
||||
msg = renderNotificationMessage(
|
||||
_('One or more encryption keys are stored in an unknown format.'),
|
||||
_('Manage'),
|
||||
onViewEncryptionConfigScreen,
|
||||
);
|
||||
} else if (props.shouldSwitchToAppleSiliconVersion) {
|
||||
msg = 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 (props.showInvalidJoplinCloudCredential) {
|
||||
msg = renderNotificationMessage(
|
||||
_('Your Joplin Cloud credentials are invalid, please login.'),
|
||||
_('Login to Joplin Cloud.'),
|
||||
onViewJoplinCloudLoginScreen,
|
||||
_('Disable synchronisation'),
|
||||
onDisableSync,
|
||||
);
|
||||
}
|
||||
|
||||
const visible = !!msg;
|
||||
const lastVisibleRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible !== lastVisibleRef.current) {
|
||||
lastVisibleRef.current = visible;
|
||||
|
||||
if (visible) {
|
||||
propsRef.current.onShow();
|
||||
} else {
|
||||
propsRef.current.onHide();
|
||||
}
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: props.height }}
|
||||
className='warning-banner -header'
|
||||
>
|
||||
<span
|
||||
className='content'
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const syncInfo = localSyncInfoFromState(state);
|
||||
const showNeedUpgradingEnabledMasterKeyMessage = !!EncryptionService.instance().masterKeysThatNeedUpgrading(syncInfo.masterKeys.filter((k) => !!k.enabled)).length;
|
||||
|
||||
return {
|
||||
hasDisabledSyncItems: state.hasDisabledSyncItems,
|
||||
hasDisabledEncryptionItems: state.hasDisabledEncryptionItems,
|
||||
showMissingMasterKeyMessage: showMissingMasterKeyMessage(syncInfo, state.notLoadedMasterKeys),
|
||||
showUnknownKeyFormatMessage: showUnknownKeyFormatBanner(syncInfo),
|
||||
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),
|
||||
shareInvitations: state.shareService.shareInvitations,
|
||||
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
|
||||
isSafeMode: state.settings.isSafeMode,
|
||||
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
|
||||
syncTargetAppMinVersion: syncInfo.appMinVersion,
|
||||
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
|
||||
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && shim.isMac() && process.arch !== 'arm64',
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(WarningBanner);
|
||||
|
||||
|
||||
type CallForActionHandler = ()=> void;
|
||||
interface NotificationMessageProps {
|
||||
message: string;
|
||||
callForAction: string|null;
|
||||
callForActionHandler: CallForActionHandler|null;
|
||||
callForAction2: string|null;
|
||||
callForActionHandler2: CallForActionHandler|null;
|
||||
}
|
||||
|
||||
const NotificationMessage: React.FC<NotificationMessageProps> = ({ message, callForAction, callForActionHandler, callForAction2, callForActionHandler2 }) => {
|
||||
if (!callForAction) return <span>{message}</span>;
|
||||
|
||||
const cfa = (
|
||||
<a href="#" className='warning-banner-link -underline' onClick={() => callForActionHandler()}>
|
||||
{callForAction}
|
||||
</a>
|
||||
);
|
||||
|
||||
const cfa2 = !callForAction2 ? null : (
|
||||
<a href="#" className='warning-banner-link -underline' 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
interface AppMinVersionIsReleaseProps {
|
||||
appMinVersion: string;
|
||||
mustUpgradeAppMessage: string;
|
||||
}
|
||||
|
||||
const useAppMinVersionState = (props: AppMinVersionIsReleaseProps) => {
|
||||
const version = props.appMinVersion;
|
||||
const [state, setState] = useState({
|
||||
isRelease: null,
|
||||
didReleaseLoadFail: false,
|
||||
});
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
setState({ isRelease: null, didReleaseLoadFail: false });
|
||||
if (!props.mustUpgradeAppMessage || !version) return;
|
||||
|
||||
try {
|
||||
const isRelease = await isReleaseVersion(version);
|
||||
if (event.cancelled) return;
|
||||
setState({
|
||||
isRelease,
|
||||
didReleaseLoadFail: isRelease === null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
if (event.cancelled) return;
|
||||
setState(state => ({
|
||||
...state,
|
||||
didReleaseLoadFail: true,
|
||||
}));
|
||||
}
|
||||
}, [version, props.mustUpgradeAppMessage]);
|
||||
|
||||
return state;
|
||||
};
|
||||
@@ -1,23 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import Dialog from '@joplin/lib/components/Dialog';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface Props {
|
||||
message: string;
|
||||
hasCloseButton: boolean;
|
||||
onClose: ()=> void;
|
||||
}
|
||||
|
||||
const ModalMessageOverlay: React.FC<Props> = ({ message }) => {
|
||||
const ModalMessageOverlay: React.FC<Props> = ({ message, hasCloseButton, onClose }) => {
|
||||
let brIndex = 1;
|
||||
const lines = message.split('\n').map((line: string) => {
|
||||
if (!line.trim()) return <br key={`${brIndex++}`}/>;
|
||||
return <div key={line} className="text">{line}</div>;
|
||||
});
|
||||
|
||||
return <Dialog contentFillsScreen={true}>
|
||||
<div className="modal-message">
|
||||
<div className="loading-animation" />
|
||||
<div className="text" role="status">
|
||||
return <Dialog contentFillsScreen={true} onCancel={hasCloseButton ? onClose : undefined}>
|
||||
<div className={`modal-message ${hasCloseButton ? '-with-close-button' : ''}`}>
|
||||
{!hasCloseButton && <div className="loading-animation" />}
|
||||
<div className={`text ${hasCloseButton ? 'modal-message-scrollable-content' : ''}`} role="status">
|
||||
{lines}
|
||||
</div>
|
||||
{hasCloseButton && <div className="modal-message-actions">
|
||||
<button type="button" onClick={onClose}>{_('OK')}</button>
|
||||
</div>}
|
||||
</div>
|
||||
</Dialog>;
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ interface Props {
|
||||
appDialogStates: AppStateDialog[];
|
||||
pluginsLegacy: Record<string, { dialogOpen?: boolean; userData?: unknown }>;
|
||||
modalMessage: string|null;
|
||||
modalMessageHasCloseButton: boolean;
|
||||
|
||||
customCss: string;
|
||||
editorNoteStatuses: EditorNoteStatuses;
|
||||
@@ -127,7 +128,11 @@ const WindowCommandsAndDialogs: React.FC<Props> = props => {
|
||||
return <>
|
||||
<div ref={setReferenceElement}/>
|
||||
{pluginDialog}
|
||||
{props.modalMessage !== null ? <ModalMessageOverlay message={props.modalMessage}/> : null}
|
||||
{props.modalMessage !== null ? <ModalMessageOverlay
|
||||
message={props.modalMessage}
|
||||
hasCloseButton={props.modalMessageHasCloseButton}
|
||||
onClose={() => props.dispatch({ type: 'HIDE_MODAL_MESSAGE' })}
|
||||
/> : null}
|
||||
<PluginDialogs
|
||||
themeId={props.themeId}
|
||||
visibleDialogs={props.visibleDialogs}
|
||||
@@ -210,5 +215,6 @@ export default connect((state: AppState, ownProps: ConnectProps) => {
|
||||
editorNoteStatuses: state.editorNoteStatuses,
|
||||
pluginsLegacy: state.pluginsLegacy,
|
||||
modalMessage: state.modalOverlayMessage,
|
||||
modalMessageHasCloseButton: state.modalOverlayHasCloseButton,
|
||||
};
|
||||
})(WindowCommandsAndDialogs);
|
||||
@@ -6,7 +6,7 @@ import bridge from '../../../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'disableNoteEncryption',
|
||||
label: () => _('Disable encryption'),
|
||||
label: () => _('Remove note lock'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import bridge from '../../../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'enableNoteEncryption',
|
||||
label: () => _('Enable encryption'),
|
||||
label: () => _('Lock this note'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
@@ -30,7 +30,7 @@ export const runtime = (): CommandRuntime => {
|
||||
}
|
||||
|
||||
if (!NoteLockKey.instance().load()) {
|
||||
const setUpNow = bridge().showConfirmMessageBox(_('Encrypting a note requires a note lock password, which has not yet been set. Set it up now?'), {
|
||||
const setUpNow = bridge().showConfirmMessageBox(_('Locking a note requires the note lock password, which has not been set up yet. Set it up now?'), {
|
||||
buttons: [_('Yes'), _('No')],
|
||||
});
|
||||
if (setUpNow) {
|
||||
|
||||
@@ -34,6 +34,7 @@ import * as renameTag from './renameTag';
|
||||
import * as resetLayout from './resetLayout';
|
||||
import * as restoreFolder from './restoreFolder';
|
||||
import * as restoreNote from './restoreNote';
|
||||
import * as revealInNotebook from './revealInNotebook';
|
||||
import * as revealResourceFile from './revealResourceFile';
|
||||
import * as search from './search';
|
||||
import * as setTags from './setTags';
|
||||
@@ -94,6 +95,7 @@ const index: any[] = [
|
||||
resetLayout,
|
||||
restoreFolder,
|
||||
restoreNote,
|
||||
revealInNotebook,
|
||||
revealResourceFile,
|
||||
search,
|
||||
setTags,
|
||||
|
||||
@@ -5,7 +5,7 @@ import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'lockEncryptedNotes',
|
||||
label: () => _('Lock encrypted notes'),
|
||||
label: () => _('Relock all notes'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { defaultWindowId, stateUtils } from '@joplin/lib/reducer';
|
||||
import { getDisplayParentId } from '@joplin/lib/services/trash';
|
||||
import bridge from '../../../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'revealInNotebook',
|
||||
label: () => _('Reveal in notebook'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, noteId: string = null) => {
|
||||
noteId = noteId || stateUtils.selectedNoteId(context.state);
|
||||
const note = await Note.load(noteId);
|
||||
if (!note) throw new Error(`No such note: ${noteId}`);
|
||||
const parentFolder = await Folder.load(note.parent_id);
|
||||
const folderId = note.is_conflict && !note.deleted_time ? Folder.conflictFolderId() : getDisplayParentId(note, parentFolder);
|
||||
const mainWindowState = stateUtils.windowStateById(context.state, defaultWindowId);
|
||||
const folderAlreadySelected = mainWindowState.selectedFolderId === folderId;
|
||||
const noteAlreadySelected = stateUtils.selectedNoteId(mainWindowState) === note.id;
|
||||
|
||||
context.dispatch({
|
||||
type: 'WINDOW_FOCUS',
|
||||
windowId: defaultWindowId,
|
||||
});
|
||||
context.dispatch({
|
||||
type: 'FOLDER_AND_NOTE_SELECT',
|
||||
folderId,
|
||||
noteId: note.id,
|
||||
});
|
||||
bridge().switchToMainWindow();
|
||||
if (folderAlreadySelected) await CommandService.instance().execute('focusElementSideBar');
|
||||
if (folderAlreadySelected && noteAlreadySelected) await CommandService.instance().execute('focusElementNoteList', note.id);
|
||||
},
|
||||
enabledCondition: 'someNotesSelected && !multipleNotesSelected',
|
||||
};
|
||||
};
|
||||
@@ -6,10 +6,11 @@ export const declaration: CommandDeclaration = {
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, message: string) => {
|
||||
execute: async (context: CommandContext, message: string, hasCloseButton = false) => {
|
||||
context.dispatch({
|
||||
type: 'SHOW_MODAL_MESSAGE',
|
||||
message,
|
||||
hasCloseButton,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function() {
|
||||
'exportDeletionLog',
|
||||
'toggleSafeMode',
|
||||
'showShareNoteDialog',
|
||||
'revealInNotebook',
|
||||
'showShareFolderDialog',
|
||||
'leaveSharedFolder',
|
||||
'gotoAnything',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.error-boundary {
|
||||
overflow: auto;
|
||||
font-family: sans-serif;
|
||||
padding: 5px 20px;
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
@@ -25,3 +25,6 @@
|
||||
@use './share-note-dialog.scss';
|
||||
@use './shared-note-list-item.scss';
|
||||
@use './inline-markdown.scss';
|
||||
@use './warning-banner-link.scss';
|
||||
@use './warning-banner.scss';
|
||||
@use './error-boundary.scss';
|
||||
@@ -3,4 +3,10 @@
|
||||
font-family: var(--joplin-font-family);
|
||||
font-size: var(--joplin-font-siize);
|
||||
font-weight: bold;
|
||||
|
||||
&.-underline {
|
||||
font-weight: normal;
|
||||
text-decoration: underline;
|
||||
color: var(--joplin-color-warn-url);
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,13 @@
|
||||
|
||||
max-height: 25vh;
|
||||
overflow-y: auto;
|
||||
|
||||
&.-header {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||