mirror of
https://github.com/laurent22/joplin.git
synced 2026-11-12 11:36:08 +00:00
Compare commits
14
Commits
server-v3.7.5
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
995b8d0e1d | ||
|
|
7130435da6 | ||
|
|
34c554f11b | ||
|
|
dda7fc08ac | ||
|
|
7cc01a63ca | ||
|
|
d197f3fc75 | ||
|
|
5914c9cb6f | ||
|
|
eab97cd2e3 | ||
|
|
b545a8ad42 | ||
|
|
953e3f7cd4 | ||
|
|
16749c0587 | ||
|
|
0e1489ce88 | ||
|
|
3795cb5a41 | ||
|
|
95c87e9deb |
No files matched your search
@@ -0,0 +1,47 @@
|
||||
name: Publish sub-packages
|
||||
|
||||
# The tag is created and pushed by `yarn publishAll`. Publishing uses npm trusted publishing, so no
|
||||
# token is needed, but each package must have this workflow registered as a trusted publisher on
|
||||
# npmjs.com.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'sub-packages-v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
# Two concurrent runs could both see a version as unpublished and then fail on the second publish
|
||||
concurrency:
|
||||
group: publish-sub-packages
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
Publish:
|
||||
if: github.repository == 'laurent22/joplin'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Trusted publishing requires registry-url, and npm >= 11.5.1, which is bundled with Node 24.18
|
||||
- name: Setup build environment
|
||||
uses: ./.github/workflows/shared/setup-build-environment
|
||||
with:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install and build
|
||||
env:
|
||||
IS_CONTINUOUS_INTEGRATION: 1
|
||||
BUILD_SEQUENCIAL: 1
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
|
||||
# Not part of the regular build, but plugin-repo-cli's entry point is dist/index.js
|
||||
- name: Bundle plugin-repo-cli
|
||||
run: yarn workspace @joplin/plugin-repo-cli dist
|
||||
|
||||
- name: Publish
|
||||
run: node packages/tools/releaseSubPackages.js publish
|
||||
@@ -1,5 +1,10 @@
|
||||
name: 'Setup build environment'
|
||||
description: 'Install Joplin build dependencies'
|
||||
inputs:
|
||||
registry-url:
|
||||
description: 'npm registry URL, required for npm trusted publishing'
|
||||
required: false
|
||||
default: ''
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
@@ -55,6 +60,7 @@ runs:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.18'
|
||||
registry-url: ${{ inputs.registry-url }}
|
||||
# Disable the cache on ARM runners. For now, we don't run "yarn install" on these
|
||||
# environments and this breaks actions/setup-node.
|
||||
# See https://github.com/laurent22/joplin/commit/47d0d3eb9e89153a609fb5441344da10904c6308#commitcomment-159577783.
|
||||
@@ -70,3 +76,10 @@ runs:
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
# Building the default plugins from source takes several minutes on Windows. Must be restored
|
||||
# before "yarn install", which builds them.
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: packages/default-plugins/build-cache
|
||||
key: default-plugins-${{ runner.os }}-${{ hashFiles('packages/default-plugins/pluginRepositories.json', 'packages/default-plugins/plugin-patches/**', 'packages/default-plugins/buildDefaultPlugins.ts') }}
|
||||
+13
-1
@@ -223,7 +223,6 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
|
||||
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
|
||||
@@ -538,6 +537,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/lockEncryptedNotes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/lockNotes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
|
||||
@@ -553,6 +553,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openPdfViewer.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openTag.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/print.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/removeNoteLockFromNotes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/resetLayout.js
|
||||
@@ -586,6 +587,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleWhiteboardEdito
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/utils/canUseNativeUndo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/setNotesLockState.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
|
||||
@@ -686,6 +688,7 @@ packages/app-desktop/services/plugins/hooks/useThemeCss.test.js
|
||||
packages/app-desktop/services/plugins/hooks/useThemeCss.js
|
||||
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
|
||||
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
|
||||
packages/app-desktop/services/plugins/startBlockedPluginsCheck.js
|
||||
packages/app-desktop/services/plugins/types.js
|
||||
packages/app-desktop/services/restart.js
|
||||
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
|
||||
@@ -1448,6 +1451,7 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.test.js
|
||||
packages/lib/components/shared/config/plugins/useOnInstallHandler.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
|
||||
packages/lib/components/shared/config/useNoteLockMode.js
|
||||
packages/lib/components/shared/dropbox-login-shared.js
|
||||
packages/lib/components/shared/note-screen-shared.test.js
|
||||
packages/lib/components/shared/note-screen-shared.js
|
||||
@@ -1824,6 +1828,8 @@ packages/lib/services/noteLock/NoteLockService.test.js
|
||||
packages/lib/services/noteLock/NoteLockService.js
|
||||
packages/lib/services/noteLock/NoteLockSession.test.js
|
||||
packages/lib/services/noteLock/NoteLockSession.js
|
||||
packages/lib/services/noteLock/bulkSetNoteLockState.test.js
|
||||
packages/lib/services/noteLock/bulkSetNoteLockState.js
|
||||
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
|
||||
packages/lib/services/noteLock/isNoteLockEnabled.js
|
||||
packages/lib/services/noteLock/noteLockPrompts.test.js
|
||||
@@ -1839,6 +1845,7 @@ packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
|
||||
packages/lib/services/ocr/utils/createAccessiblePdf.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.test.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.js
|
||||
packages/lib/services/ocr/utils/glyphLessFont.js
|
||||
packages/lib/services/ocr/utils/types.js
|
||||
packages/lib/services/plugins/BasePlatformImplementation.js
|
||||
packages/lib/services/plugins/BasePluginRunner.js
|
||||
@@ -1881,8 +1888,11 @@ packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
|
||||
packages/lib/services/plugins/loadPlugins.test.js
|
||||
packages/lib/services/plugins/loadPlugins.js
|
||||
packages/lib/services/plugins/reducer.js
|
||||
packages/lib/services/plugins/refreshBlockedPluginVersions.js
|
||||
packages/lib/services/plugins/testing/MockPlatformImplementation.js
|
||||
packages/lib/services/plugins/testing/MockPluginRunner.js
|
||||
packages/lib/services/plugins/utils/blockedVersions.test.js
|
||||
packages/lib/services/plugins/utils/blockedVersions.js
|
||||
packages/lib/services/plugins/utils/createViewHandle.js
|
||||
packages/lib/services/plugins/utils/executeSandboxCall.js
|
||||
packages/lib/services/plugins/utils/getActivePluginEditorView.js
|
||||
@@ -2258,6 +2268,8 @@ packages/tools/release-ios.js
|
||||
packages/tools/release-plugin-repo-cli.js
|
||||
packages/tools/release-server.js
|
||||
packages/tools/release-transcribe.js
|
||||
packages/tools/releaseSubPackages.test.js
|
||||
packages/tools/releaseSubPackages.js
|
||||
packages/tools/saveClaConsentRecords.js
|
||||
packages/tools/setupNewRelease.js
|
||||
packages/tools/spellcheck.js
|
||||
|
||||
+13
-1
@@ -249,7 +249,6 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
|
||||
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
|
||||
@@ -564,6 +563,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/lockEncryptedNotes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/lockNotes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
|
||||
@@ -579,6 +579,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openPdfViewer.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openTag.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/print.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/removeNoteLockFromNotes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/renameTag.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/resetLayout.js
|
||||
@@ -612,6 +613,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleWhiteboardEdito
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/utils/canUseNativeUndo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/setNotesLockState.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
|
||||
@@ -712,6 +714,7 @@ packages/app-desktop/services/plugins/hooks/useThemeCss.test.js
|
||||
packages/app-desktop/services/plugins/hooks/useThemeCss.js
|
||||
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
|
||||
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
|
||||
packages/app-desktop/services/plugins/startBlockedPluginsCheck.js
|
||||
packages/app-desktop/services/plugins/types.js
|
||||
packages/app-desktop/services/restart.js
|
||||
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
|
||||
@@ -1474,6 +1477,7 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.test.js
|
||||
packages/lib/components/shared/config/plugins/useOnInstallHandler.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
|
||||
packages/lib/components/shared/config/useNoteLockMode.js
|
||||
packages/lib/components/shared/dropbox-login-shared.js
|
||||
packages/lib/components/shared/note-screen-shared.test.js
|
||||
packages/lib/components/shared/note-screen-shared.js
|
||||
@@ -1850,6 +1854,8 @@ packages/lib/services/noteLock/NoteLockService.test.js
|
||||
packages/lib/services/noteLock/NoteLockService.js
|
||||
packages/lib/services/noteLock/NoteLockSession.test.js
|
||||
packages/lib/services/noteLock/NoteLockSession.js
|
||||
packages/lib/services/noteLock/bulkSetNoteLockState.test.js
|
||||
packages/lib/services/noteLock/bulkSetNoteLockState.js
|
||||
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
|
||||
packages/lib/services/noteLock/isNoteLockEnabled.js
|
||||
packages/lib/services/noteLock/noteLockPrompts.test.js
|
||||
@@ -1865,6 +1871,7 @@ packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
|
||||
packages/lib/services/ocr/utils/createAccessiblePdf.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.test.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.js
|
||||
packages/lib/services/ocr/utils/glyphLessFont.js
|
||||
packages/lib/services/ocr/utils/types.js
|
||||
packages/lib/services/plugins/BasePlatformImplementation.js
|
||||
packages/lib/services/plugins/BasePluginRunner.js
|
||||
@@ -1907,8 +1914,11 @@ packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
|
||||
packages/lib/services/plugins/loadPlugins.test.js
|
||||
packages/lib/services/plugins/loadPlugins.js
|
||||
packages/lib/services/plugins/reducer.js
|
||||
packages/lib/services/plugins/refreshBlockedPluginVersions.js
|
||||
packages/lib/services/plugins/testing/MockPlatformImplementation.js
|
||||
packages/lib/services/plugins/testing/MockPluginRunner.js
|
||||
packages/lib/services/plugins/utils/blockedVersions.test.js
|
||||
packages/lib/services/plugins/utils/blockedVersions.js
|
||||
packages/lib/services/plugins/utils/createViewHandle.js
|
||||
packages/lib/services/plugins/utils/executeSandboxCall.js
|
||||
packages/lib/services/plugins/utils/getActivePluginEditorView.js
|
||||
@@ -2284,6 +2294,8 @@ packages/tools/release-ios.js
|
||||
packages/tools/release-plugin-repo-cli.js
|
||||
packages/tools/release-server.js
|
||||
packages/tools/release-transcribe.js
|
||||
packages/tools/releaseSubPackages.test.js
|
||||
packages/tools/releaseSubPackages.js
|
||||
packages/tools/saveClaConsentRecords.js
|
||||
packages/tools/setupNewRelease.js
|
||||
packages/tools/spellcheck.js
|
||||
|
||||
+7
-12
@@ -1,6 +1,7 @@
|
||||
const gulp = require('gulp');
|
||||
const execa = require('execa');
|
||||
const { stdout } = require('process');
|
||||
const { readFileSync } = require('fs');
|
||||
|
||||
const execCommand = async (executableName, args, options = null) => {
|
||||
options = {
|
||||
@@ -32,21 +33,15 @@ const execCommand = async (executableName, args, options = null) => {
|
||||
const tasks = {
|
||||
completePublishAll: {
|
||||
fn: async () => {
|
||||
await execCommand('yarn', ['install', '--mode=update-lockfile']);
|
||||
await execCommand('git', ['add', '-A']);
|
||||
await execCommand('git', ['commit', '-m', 'Releasing sub-packages']);
|
||||
|
||||
// Lerna does some unnecessary auth check that doesn't work with
|
||||
// automation tokens, thus the --no-verify-access. Automation token
|
||||
// is still used for access when publishing even with this flag
|
||||
// (publishing would fail otherwise).
|
||||
// https://github.com/lerna/lerna/issues/2788
|
||||
await execCommand('lerna', ['publish', 'from-package', '-y', '--no-verify-access']);
|
||||
|
||||
await execCommand('yarn', ['install']);
|
||||
await execCommand('git', ['add', '-A']);
|
||||
await execCommand('git', ['commit', '-m', 'Lock file']);
|
||||
|
||||
await execCommand('git', ['push']);
|
||||
// Pushing the tag triggers the publish-sub-packages workflow, which publishes the packages
|
||||
const libVersion = JSON.parse(readFileSync('packages/lib/package.json', 'utf8')).version;
|
||||
const tagName = `sub-packages-v${libVersion}`;
|
||||
await execCommand('git', ['tag', tagName]);
|
||||
await execCommand('git', ['push', '--atomic', 'origin', 'HEAD', tagName]);
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "independent",
|
||||
"useWorkspaces": true
|
||||
}
|
||||
+1
-2
@@ -42,7 +42,7 @@
|
||||
"syncFuzzer": "node ./packages/tools/fuzzer/cli.js",
|
||||
"postinstall": "husky && gulp build",
|
||||
"postPreReleasesToForum": "node ./packages/tools/postPreReleasesToForum",
|
||||
"publishAll": "git pull && yarn buildParallel && lerna version --yes --no-private --no-git-tag-version && gulp completePublishAll",
|
||||
"publishAll": "git pull && yarn tsc && node packages/tools/releaseSubPackages.js version && gulp completePublishAll",
|
||||
"releaseAndroid": "PATH=\"/usr/local/opt/openjdk@17/bin:$PATH\" node packages/tools/release-android.js",
|
||||
"releaseAndroidClean": "node packages/tools/release-android.js",
|
||||
"releaseCli": "node packages/tools/release-cli.js",
|
||||
@@ -92,7 +92,6 @@
|
||||
"globals": "17.7.0",
|
||||
"gulp": "5.0.1",
|
||||
"husky": "9.1.7",
|
||||
"lerna": "3.22.1",
|
||||
"lint-staged": "16.4.0",
|
||||
"madge": "8.0.0",
|
||||
"npm-package-json-lint": "10.4.1",
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { AppType } from '@joplin/lib/models/Setting';
|
||||
import RepositoryApi, { AppInfo, InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import refreshBlockedPluginVersions from '@joplin/lib/services/plugins/refreshBlockedPluginVersions';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, supportDir, createTempDir } from '@joplin/lib/testing/test-utils';
|
||||
import { remove } from 'fs-extra';
|
||||
import { copy, mkdirp, readJson, remove, writeFile, writeJson } from 'fs-extra';
|
||||
|
||||
let tempDirs: string[] = [];
|
||||
async function newRepoApi(appInfo: AppInfo = { type: AppType.Desktop, version: '3.0.0' }): Promise<RepositoryApi> {
|
||||
async function newRepoApi(appInfo: AppInfo = { type: AppType.Desktop, version: '3.0.0' }, blockedVersions: Record<string, string[]> = null): Promise<RepositoryApi> {
|
||||
const tempDir = await createTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const repo = new RepositoryApi(`${supportDir}/pluginRepo`, tempDir, appInfo, InstallMode.Default);
|
||||
|
||||
let repoDir = `${supportDir}/pluginRepo`;
|
||||
if (blockedVersions) {
|
||||
repoDir = `${tempDir}/pluginRepo`;
|
||||
await copy(`${supportDir}/pluginRepo`, repoDir);
|
||||
const manifests = await readJson(`${repoDir}/manifests.json`);
|
||||
for (const [id, versions] of Object.entries(blockedVersions)) manifests[id]._blocked_versions = versions;
|
||||
await writeJson(`${repoDir}/manifests.json`, manifests);
|
||||
}
|
||||
|
||||
const repo = new RepositoryApi(repoDir, tempDir, appInfo, InstallMode.Default);
|
||||
await repo.initialize();
|
||||
return repo;
|
||||
}
|
||||
@@ -67,4 +79,33 @@ describe('services_plugins_RepositoryApi', () => {
|
||||
expect(await api.pluginCanBeUpdated(id, installedVersion)).toBe(shouldBeUpdatable);
|
||||
}));
|
||||
|
||||
it.each([
|
||||
['1.0.2'],
|
||||
['>=1.0.1'],
|
||||
['*'],
|
||||
])('should not update to or download a blocked version (%s)', (async (blockedVersion) => {
|
||||
const id = 'org.joplinapp.plugins.ToggleSidebars';
|
||||
const api = await newRepoApi(undefined, { [id]: [blockedVersion] });
|
||||
expect(await api.pluginCanBeUpdated(id, '1.0.0')).toBe(false);
|
||||
await expect(api.downloadPlugin(id)).rejects.toThrow('has been blocked');
|
||||
}));
|
||||
|
||||
it('should cache the blocked versions only when plugins are installed', (async () => {
|
||||
const blockedVersions = { 'org.joplinapp.plugins.ToggleSidebars': ['1.0.1', '1.0.2'] };
|
||||
const api = await newRepoApi(undefined, blockedVersions);
|
||||
|
||||
await refreshBlockedPluginVersions(api);
|
||||
expect(Setting.value('plugins.blockedVersions')).toEqual({});
|
||||
|
||||
const pluginPath = `${Setting.value('pluginDir')}/test.jpl`;
|
||||
await mkdirp(Setting.value('pluginDir'));
|
||||
await writeFile(pluginPath, '');
|
||||
try {
|
||||
await refreshBlockedPluginVersions(api);
|
||||
expect(Setting.value('plugins.blockedVersions')).toEqual(blockedVersions);
|
||||
} finally {
|
||||
await remove(pluginPath);
|
||||
}
|
||||
}));
|
||||
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { _, setLocale } from '@joplin/lib/locale';
|
||||
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
|
||||
import SpellCheckerServiceDriverNative from './services/spellChecker/SpellCheckerServiceDriverNative';
|
||||
import bridge from './services/bridge';
|
||||
import startBlockedPluginsCheck from './services/plugins/startBlockedPluginsCheck';
|
||||
import menuCommandNames from './gui/menuCommandNames';
|
||||
import ResourceService from '@joplin/lib/services/ResourceService';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
@@ -385,6 +386,8 @@ class Application extends BaseApplication {
|
||||
ipcRenderer.send('startup-plugins-loaded');
|
||||
|
||||
void afterDefaultPluginsLoaded(service.plugins, getDefaultPluginsInfo(), pluginSettings);
|
||||
|
||||
startBlockedPluginsCheck(this.store());
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -9,20 +9,13 @@ import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import LabelledPasswordInput from '../../PasswordInput/LabelledPasswordInput';
|
||||
import Button, { ButtonLevel } from '../../Button/Button';
|
||||
import { AppState } from '../../../app.reducer';
|
||||
import useNoteLockMode, { ActionMode } from './useNoteLockMode';
|
||||
import useNoteLockMode, { ActionMode, noteLockPasswordErrorMessage } from '@joplin/lib/components/shared/config/useNoteLockMode';
|
||||
|
||||
interface Props {
|
||||
hasNoteLockKey: boolean;
|
||||
needsNoteLockKeyUpgrade: boolean;
|
||||
}
|
||||
|
||||
// WebCrypto reports a wrong password as an OperationError with an unhelpful generic message
|
||||
const errorMessage = (error: unknown) => {
|
||||
if (!(error instanceof Error)) return String(error);
|
||||
if (error.name === 'OperationError') return _('Invalid password');
|
||||
return error.message;
|
||||
};
|
||||
|
||||
const NoteLockSettings: React.FC<Props> = props => {
|
||||
const hasKey = props.hasNoteLockKey;
|
||||
const {
|
||||
@@ -36,17 +29,21 @@ const NoteLockSettings: React.FC<Props> = props => {
|
||||
setPasswordRepeat,
|
||||
error,
|
||||
setError,
|
||||
saving,
|
||||
setSaving,
|
||||
passwordMismatch,
|
||||
canSave,
|
||||
forgotPasswordDescription,
|
||||
resetPasswordTitle,
|
||||
passwordReminder,
|
||||
title,
|
||||
} = useNoteLockMode(hasKey);
|
||||
|
||||
const [upgradePassword, setUpgradePassword] = useState('');
|
||||
const [upgradeError, setUpgradeError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const passwordMismatch = !!passwordRepeat && password !== passwordRepeat;
|
||||
const canSave = !!password && !!passwordRepeat && !passwordMismatch && !saving && (mode !== ActionMode.Change || !!currentPassword);
|
||||
const canUpgrade = !!upgradePassword && !saving;
|
||||
|
||||
const forgotPasswordDescription = _('Only do this if you\'ve forgotten your current password. A new password will be created, and any notes locked with the old one will become permanently unreadable.');
|
||||
const forgotPasswordWarning = `${_('Warning:')} ${forgotPasswordDescription}`;
|
||||
const resetConfirmationMessage = `${forgotPasswordWarning}\n\n${_('Continue?')}`;
|
||||
|
||||
@@ -89,11 +86,11 @@ const NoteLockSettings: React.FC<Props> = props => {
|
||||
}
|
||||
onModeChange(ActionMode.Collapsed);
|
||||
} catch (error) {
|
||||
setError(errorMessage(error));
|
||||
setError(noteLockPasswordErrorMessage(error, true));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [canSave, onModeChange, currentPassword, mode, password, setError, resetConfirmationMessage]);
|
||||
}, [canSave, onModeChange, currentPassword, mode, password, setError, setSaving, resetConfirmationMessage]);
|
||||
|
||||
const submitUpgrade = useCallback(async () => {
|
||||
if (!canUpgrade) return;
|
||||
@@ -104,11 +101,11 @@ const NoteLockSettings: React.FC<Props> = props => {
|
||||
await NoteLockKey.instance().upgrade(upgradePassword);
|
||||
setUpgradePassword('');
|
||||
} catch (error) {
|
||||
setUpgradeError(errorMessage(error));
|
||||
setUpgradeError(noteLockPasswordErrorMessage(error, true));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [canUpgrade, upgradePassword]);
|
||||
}, [canUpgrade, upgradePassword, setSaving]);
|
||||
|
||||
const onChangePasswordClick = useCallback(() => {
|
||||
onModeChange(ActionMode.Change);
|
||||
@@ -122,19 +119,7 @@ const NoteLockSettings: React.FC<Props> = props => {
|
||||
onModeChange(ActionMode.Collapsed);
|
||||
}, [onModeChange]);
|
||||
|
||||
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;
|
||||
if (mode === ActionMode.Change) return _('Change note lock password');
|
||||
if (mode === ActionMode.Collapsed) return _('Manage password');
|
||||
return _('Password setup');
|
||||
};
|
||||
|
||||
const renderManageButtons = () => {
|
||||
return (
|
||||
@@ -237,7 +222,7 @@ const NoteLockSettings: React.FC<Props> = props => {
|
||||
return (
|
||||
<div className='note-lock-settings'>
|
||||
<div className='section'>
|
||||
<h2>{getSectionTitle()}</h2>
|
||||
<h2>{title}</h2>
|
||||
{mode === ActionMode.Collapsed ? renderManageButtons() : renderPasswordForm()}
|
||||
</div>
|
||||
{renderUpgradeSection()}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export enum ActionMode {
|
||||
Collapsed = 'collapsed',
|
||||
Create = 'create',
|
||||
Change = 'change',
|
||||
Reset = 'reset',
|
||||
}
|
||||
|
||||
const useNoteLockMode = (hasKey: boolean) => {
|
||||
const [mode, setMode] = useState<ActionMode>(hasKey ? ActionMode.Collapsed : ActionMode.Create);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordRepeat, setPasswordRepeat] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const clearForm = useCallback(() => {
|
||||
setCurrentPassword('');
|
||||
setPassword('');
|
||||
setPasswordRepeat('');
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const onModeChange = useCallback((newMode: ActionMode) => {
|
||||
setMode(newMode);
|
||||
clearForm();
|
||||
}, [clearForm]);
|
||||
|
||||
// The key can also appear or disappear through sync while this screen is open;
|
||||
// clear any drafted input along with the mode switch.
|
||||
useEffect(() => {
|
||||
if (hasKey && mode === ActionMode.Create) onModeChange(ActionMode.Collapsed);
|
||||
if (!hasKey && mode !== ActionMode.Create) onModeChange(ActionMode.Create);
|
||||
}, [hasKey, mode, onModeChange]);
|
||||
|
||||
return {
|
||||
mode,
|
||||
onModeChange,
|
||||
currentPassword,
|
||||
setCurrentPassword,
|
||||
password,
|
||||
setPassword,
|
||||
passwordRepeat,
|
||||
setPasswordRepeat,
|
||||
error,
|
||||
setError,
|
||||
};
|
||||
};
|
||||
|
||||
export default useNoteLockMode;
|
||||
@@ -2,9 +2,13 @@ import * as React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { noteLockPasswordErrorMessage } from '@joplin/lib/components/shared/config/useNoteLockMode';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import Button, { ButtonLevel } from '../../Button/Button';
|
||||
import LabelledPasswordInput from '../../PasswordInput/LabelledPasswordInput';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('NoteLockPanel');
|
||||
|
||||
interface Props {
|
||||
noteTitle: string;
|
||||
@@ -31,8 +35,8 @@ export default function NoteLockPanel(props: Props) {
|
||||
await NoteLockSession.instance().unlock(password);
|
||||
// No success handling: the unlock event swaps this panel out for the note.
|
||||
} catch (error) {
|
||||
// WebCrypto reports a wrong password as a generic OperationError.
|
||||
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
|
||||
logger.warn('Could not unlock the note lock session:', error);
|
||||
setErrorMessage(noteLockPasswordErrorMessage(error));
|
||||
setUnlocking(false);
|
||||
}
|
||||
}, [password, unlocking]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { noteLockPasswordErrorMessage } from '@joplin/lib/components/shared/config/useNoteLockMode';
|
||||
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
|
||||
import Dialog from '@joplin/lib/components/Dialog';
|
||||
import DialogTitle from '../DialogTitle';
|
||||
@@ -51,7 +52,7 @@ export default function(props: Props) {
|
||||
try {
|
||||
await startNoteLockKeyMigration(localPassword, targetPassword, props.dispatch, onClose);
|
||||
} catch (error) {
|
||||
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
|
||||
setErrorMessage(noteLockPasswordErrorMessage(error, true));
|
||||
setMigrating(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@ import * as React from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { noteLockPasswordErrorMessage } from '@joplin/lib/components/shared/config/useNoteLockMode';
|
||||
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
|
||||
import Dialog from '@joplin/lib/components/Dialog';
|
||||
import DialogTitle from '../DialogTitle';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import LabelledPasswordInput from '../PasswordInput/LabelledPasswordInput';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('NoteLockUnlockDialog');
|
||||
|
||||
interface SuccessCommand {
|
||||
name: string;
|
||||
@@ -47,8 +51,8 @@ export default function(props: Props) {
|
||||
try {
|
||||
await NoteLockSession.instance().unlock(password);
|
||||
} catch (error) {
|
||||
// WebCrypto reports a wrong password as a generic OperationError.
|
||||
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
|
||||
logger.warn('Could not unlock the note lock session:', error);
|
||||
setErrorMessage(noteLockPasswordErrorMessage(error));
|
||||
setUnlocking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import useOnItemClick from './useOnItemClick';
|
||||
import { ShareType, StateShare } from '@joplin/lib/services/share/reducer';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem: typeof MenuItemType = bridge().MenuItem;
|
||||
@@ -273,6 +274,12 @@ const useOnRenderItem = (props: Props) => {
|
||||
checked: PerFolderSortOrderService.isSet(itemId),
|
||||
}));
|
||||
}
|
||||
|
||||
if (isNoteLockEnabled()) {
|
||||
// Read-only notes are skipped, so the items stay enabled whatever the selected notes are.
|
||||
menu.append(new MenuItem({ ...menuUtils.commandToStatefulMenuItem('lockNotes', { folderId: itemId }), label: _('Lock all notes'), enabled: true }));
|
||||
menu.append(new MenuItem({ ...menuUtils.commandToStatefulMenuItem('removeNoteLockFromNotes', { folderId: itemId }), label: _('Remove lock from all notes'), enabled: true }));
|
||||
}
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER && itemIds.length === 1) {
|
||||
|
||||
@@ -37,6 +37,7 @@ interface Props {
|
||||
hasDisabledSyncItems: boolean;
|
||||
showUnknownKeyFormatMessage: boolean;
|
||||
showMissingMasterKeyMessage: boolean;
|
||||
noteLockBulkChangeRunning: boolean;
|
||||
showNoteLockKeyConflictMessage: boolean;
|
||||
noteLockMigrationStatus: NoteLockMigrationStatus|null;
|
||||
mustUpgradeAppMessage: string;
|
||||
@@ -162,6 +163,8 @@ const WarningBanner: React.FC<Props> = props => {
|
||||
_('Set the password'),
|
||||
onViewSyncSettingsScreen,
|
||||
);
|
||||
} else if (props.noteLockBulkChangeRunning) {
|
||||
msg = renderNotificationMessage(_('Applying note lock changes. Please wait…'));
|
||||
} else if (props.noteLockMigrationStatus?.running) {
|
||||
msg = renderNotificationMessage(_('Re-encrypting your locked notes with the synced key. Migration is in progress...'));
|
||||
} else if (props.noteLockMigrationStatus?.failed || props.noteLockMigrationStatus?.awaitingDecryption) {
|
||||
@@ -341,6 +344,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
shareInvitations: state.shareService.shareInvitations,
|
||||
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
|
||||
isSafeMode: state.settings.isSafeMode,
|
||||
noteLockBulkChangeRunning: state.noteLockBulkChangeRunning,
|
||||
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
|
||||
syncTargetAppMinVersion: state.mustUpgradeAppMinVersion,
|
||||
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as hideModalMessage from './hideModalMessage';
|
||||
import * as importFrom from './importFrom';
|
||||
import * as linkToNote from './linkToNote';
|
||||
import * as lockEncryptedNotes from './lockEncryptedNotes';
|
||||
import * as lockNotes from './lockNotes';
|
||||
import * as moveToFolder from './moveToFolder';
|
||||
import * as newFolder from './newFolder';
|
||||
import * as newNote from './newNote';
|
||||
@@ -29,6 +30,7 @@ import * as openNote from './openNote';
|
||||
import * as openPdfViewer from './openPdfViewer';
|
||||
import * as openTag from './openTag';
|
||||
import * as print from './print';
|
||||
import * as removeNoteLockFromNotes from './removeNoteLockFromNotes';
|
||||
import * as renameFolder from './renameFolder';
|
||||
import * as renameTag from './renameTag';
|
||||
import * as resetLayout from './resetLayout';
|
||||
@@ -76,6 +78,7 @@ const index: any[] = [
|
||||
importFrom,
|
||||
linkToNote,
|
||||
lockEncryptedNotes,
|
||||
lockNotes,
|
||||
moveToFolder,
|
||||
newFolder,
|
||||
newNote,
|
||||
@@ -90,6 +93,7 @@ const index: any[] = [
|
||||
openPdfViewer,
|
||||
openTag,
|
||||
print,
|
||||
removeNoteLockFromNotes,
|
||||
renameFolder,
|
||||
renameTag,
|
||||
resetLayout,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { DesktopCommandContext } from '../../../services/commands/types';
|
||||
import setNotesLockState, { BulkNoteLockTarget } from '../utils/setNotesLockState';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'lockNotes',
|
||||
label: () => _('Lock these notes'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: DesktopCommandContext, target: BulkNoteLockTarget = null) => {
|
||||
await setNotesLockState(context, target, true);
|
||||
},
|
||||
enabledCondition: 'someNotesSelected && !inTrash && !inConflictFolder',
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { DesktopCommandContext } from '../../../services/commands/types';
|
||||
import setNotesLockState, { BulkNoteLockTarget } from '../utils/setNotesLockState';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'removeNoteLockFromNotes',
|
||||
label: () => _('Remove note lock from these notes'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: DesktopCommandContext, target: BulkNoteLockTarget = null) => {
|
||||
await setNotesLockState(context, target, false);
|
||||
},
|
||||
enabledCondition: 'someNotesSelected && !inTrash && !inConflictFolder',
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import { folderTreeNoteIds, promptAndBulkSetNoteLockState } from '@joplin/lib/services/noteLock/bulkSetNoteLockState';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
import { DesktopCommandContext } from '../../../services/commands/types';
|
||||
import noteLockPrompts from '../../../utils/noteLockPrompts';
|
||||
import bridge from '../../../services/bridge';
|
||||
|
||||
export type BulkNoteLockTarget = string[]|{ folderId: string };
|
||||
|
||||
const setNotesLockState = async (context: DesktopCommandContext, target: BulkNoteLockTarget, isLocked: boolean) => {
|
||||
if (!isNoteLockEnabled()) return;
|
||||
const noteIds = !target ? context.state.selectedNoteIds : Array.isArray(target) ? target : await folderTreeNoteIds(target.folderId);
|
||||
|
||||
// Same pending-save guard as enableNoteEncryption.
|
||||
if (noteIds.some(noteId => context.state.editorNoteStatuses[noteId] === 'saving')) {
|
||||
bridge().showErrorMessageBox(_('This note is currently being saved. Please try again in a moment.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// An externally edited note has a plaintext file on disk; flush and delete it before locking.
|
||||
const stopExternalEdits = async () => {
|
||||
for (const noteId of noteIds) {
|
||||
if (context.state.watchedNoteFiles.includes(noteId)) await ExternalEditWatcher.instance().stopWatching(noteId);
|
||||
}
|
||||
};
|
||||
await promptAndBulkSetNoteLockState(noteIds, isLocked, noteLockPrompts(context.dispatch), context.dispatch, {
|
||||
wholeNotebook: !!target && !Array.isArray(target),
|
||||
beforeChange: isLocked ? stopExternalEdits : null,
|
||||
});
|
||||
};
|
||||
|
||||
export default setNotesLockState;
|
||||
@@ -15,6 +15,7 @@ import { Dispatch } from 'redux';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import hasLockedNoteWhileSessionLocked from '@joplin/lib/services/noteLock/hasLockedNoteWhileSessionLocked';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
@@ -159,6 +160,14 @@ export default class NoteListUtils {
|
||||
menu.append(new MenuItem({ type: 'separator' }));
|
||||
}
|
||||
|
||||
if (noteIds.length > 1 && isNoteLockEnabled()) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('lockNotes', noteIds) as any));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- commandToStatefulMenuItem returns lib's MenuItem shape which doesn't structurally match Electron's MenuItemConstructorOptions
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('removeNoteLockFromNotes', noteIds) as any));
|
||||
menu.append(new MenuItem({ type: 'separator' }));
|
||||
}
|
||||
|
||||
if ([9, 10, 11].includes(Setting.value('sync.target'))) {
|
||||
menu.append(
|
||||
new MenuItem(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Setting, { AppType } from '@joplin/lib/models/Setting';
|
||||
import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import refreshBlockedPluginVersions from '@joplin/lib/services/plugins/refreshBlockedPluginVersions';
|
||||
import { State, stateUtils } from '@joplin/lib/reducer';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import time from '@joplin/lib/time';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { Store } from 'redux';
|
||||
import bridge from '../bridge';
|
||||
import restart from '../restart';
|
||||
|
||||
const logger = Logger.create('startBlockedPluginsCheck');
|
||||
|
||||
const waitForNotesSaved = async (store: Store<State>) => {
|
||||
const startTime = Date.now();
|
||||
while (stateUtils.hasNotesBeingSaved(store.getState()) && Date.now() - startTime < 5000) {
|
||||
await time.msleep(100);
|
||||
}
|
||||
};
|
||||
|
||||
// A blocked plugin can't be fully unloaded on desktop since its content scripts run in the main
|
||||
// window, so we restart straight away rather than wait for the user, who may not be at their computer.
|
||||
const restartForBlockedPlugins = async (store: Store<State>, pluginIds: string[]) => {
|
||||
logger.warn('Restarting because these running plugins have been blocked:', pluginIds);
|
||||
const service = PluginService.instance();
|
||||
Setting.setValue('plugins.blockedRestartNotice', pluginIds.map(id => service.pluginById(id).manifest.name));
|
||||
await waitForNotesSaved(store);
|
||||
await restart();
|
||||
};
|
||||
|
||||
const showBlockedRestartNotice = () => {
|
||||
const pluginNames: string[] = Setting.value('plugins.blockedRestartNotice');
|
||||
if (!pluginNames.length) return;
|
||||
Setting.setValue('plugins.blockedRestartNotice', []);
|
||||
bridge().showInfoMessageBox(_('Joplin was restarted because the following plugins have been disabled by the Joplin team for security reasons: %s\n\nWe recommend uninstalling them.', pluginNames.join(', ')));
|
||||
};
|
||||
|
||||
const startBlockedPluginsCheck = (store: Store<State>) => {
|
||||
showBlockedRestartNotice();
|
||||
|
||||
const repoApi = RepositoryApi.ofDefaultJoplinRepo(
|
||||
Setting.value('tempDir'), { type: AppType.Desktop, version: PluginService.instance().appVersion }, InstallMode.Default,
|
||||
);
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
const blockedRunningPluginIds = await refreshBlockedPluginVersions(repoApi);
|
||||
if (blockedRunningPluginIds.length) await restartForBlockedPlugins(store, blockedRunningPluginIds);
|
||||
} catch (error) {
|
||||
logger.warn('Could not refresh blocked plugin versions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
void check();
|
||||
shim.setInterval(() => { void check(); }, 60 * 60 * 1000);
|
||||
};
|
||||
|
||||
export default startBlockedPluginsCheck;
|
||||
@@ -198,6 +198,20 @@ describe('WarningBanner', () => {
|
||||
expect(screen.queryByRole('button', { name: buttonName })).toBeNull();
|
||||
});
|
||||
|
||||
test('should show the bulk note lock banner only while the notes change', () => {
|
||||
const mock = createMockStore();
|
||||
render(<WarningBannerWrapper store={mock.store}/>);
|
||||
const setRunning = (value: boolean) => act(() => {
|
||||
mock.store.dispatch({ type: 'NOTE_LOCK_BULK_CHANGE_RUNNING_SET', value });
|
||||
});
|
||||
|
||||
setRunning(true);
|
||||
expect(screen.getByText('Applying note lock changes. Please wait…')).toBeVisible();
|
||||
|
||||
setRunning(false);
|
||||
expect(screen.queryByText('Applying note lock changes. Please wait…')).toBeNull();
|
||||
});
|
||||
|
||||
test('should show the sync target minimum version in the upgrade app banner', async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
|
||||
@@ -34,6 +34,7 @@ interface Props {
|
||||
shareInvitations: ShareInvitation[];
|
||||
processingShareInvitationResponse: boolean;
|
||||
showInvalidJoplinCloudCredential: boolean;
|
||||
noteLockBulkChangeRunning: boolean;
|
||||
noteLockKeyConflict: boolean;
|
||||
showNoteLockKeyConflictMessage: boolean|undefined;
|
||||
noteLockMigrationStatus: NoteLockMigrationStatus|null;
|
||||
@@ -157,6 +158,9 @@ const WarningBannerComponent: React.FC<Props> = props => {
|
||||
const target = { screen: 'JoplinCloudLogin' };
|
||||
warningComps.push(renderWarningBox('syncLogin', _('Your Joplin Cloud credentials are invalid, please login.'), target));
|
||||
}
|
||||
if (props.noteLockBulkChangeRunning) {
|
||||
warningComps.push(renderWarningBox('noteLockBulkChange', _('Applying note lock changes. Please wait…'), null));
|
||||
}
|
||||
|
||||
const shareInvitation = props.shareInvitations.find(inv => inv.status === ShareUserStatus.Waiting);
|
||||
if (
|
||||
@@ -210,6 +214,7 @@ export default connect((state: AppState) => {
|
||||
shareInvitations: state.shareService.shareInvitations,
|
||||
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
|
||||
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && !isSyncLoginRoute(state) && state.mustAuthenticate,
|
||||
noteLockBulkChangeRunning: state.noteLockBulkChangeRunning,
|
||||
noteLockKeyConflict: noteLockKeyConflictFromState(state),
|
||||
noteLockMigrationStatus: state.noteLockMigrationStatus,
|
||||
};
|
||||
|
||||
@@ -28,6 +28,10 @@ import { ALL_NOTES_FILTER_ID } from '@joplin/lib/reserved-ids';
|
||||
import SidebarIcon from './SidebarIcon';
|
||||
import SideMenuItem, { ToggleState } from './SideMenuItem';
|
||||
import BottomPanelActions from './BottomPanelActions';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import { NoteLockPrompts } from '@joplin/lib/services/noteLock/noteLockPrompts';
|
||||
import { folderTreeNoteIds, promptAndBulkSetNoteLockState } from '@joplin/lib/services/noteLock/bulkSetNoteLockState';
|
||||
|
||||
interface Props {
|
||||
syncStarted: boolean;
|
||||
@@ -217,7 +221,25 @@ const SideMenuContentComponent = (props: Props) => {
|
||||
},
|
||||
};
|
||||
|
||||
menuItems.push(editButton, deleteButton);
|
||||
menuItems.push(editButton);
|
||||
if (isNoteLockEnabled()) {
|
||||
const noteLockPrompts: NoteLockPrompts = {
|
||||
promptPassword: label => dialogs.promptForText(label, '', true),
|
||||
goToNoteLockSetup: () => {
|
||||
props.dispatch({ type: 'SIDE_MENU_CLOSE' });
|
||||
void NavService.go('Config', { sectionName: 'noteLock' });
|
||||
},
|
||||
};
|
||||
const noteLockButton = (isLocked: boolean): MenuOption => ({
|
||||
title: isLocked ? _('Lock all notes') : _('Remove lock from all notes'),
|
||||
icon: isLocked ? 'material lock-outline' : 'material lock-open-outline',
|
||||
onPress: async () => {
|
||||
await promptAndBulkSetNoteLockState(await folderTreeNoteIds(folder.id), isLocked, noteLockPrompts, props.dispatch, { wholeNotebook: true });
|
||||
},
|
||||
});
|
||||
menuItems.push(noteLockButton(true), noteLockButton(false));
|
||||
}
|
||||
menuItems.push(deleteButton);
|
||||
}
|
||||
|
||||
setFolderMenuOptions(menuItems);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { connect, useStore } from 'react-redux';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { BlockedPluginVersions } from '@joplin/lib/services/plugins/utils/blockedVersions';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import PluginDialogManager from './dialogs/PluginDialogManager';
|
||||
import { AppState } from '../../utils/types';
|
||||
@@ -33,6 +34,7 @@ const usePlugins = (
|
||||
pluginSettings: PluginSettings,
|
||||
pluginSupportEnabled: boolean,
|
||||
devPluginPath: string,
|
||||
blockedPluginVersions: BlockedPluginVersions,
|
||||
) => {
|
||||
const store = useStore<AppState>();
|
||||
const lastPluginRunner = usePrevious(pluginRunner);
|
||||
@@ -71,7 +73,7 @@ const usePlugins = (
|
||||
if (!event.cancelled) {
|
||||
reloadAllRef.current = false;
|
||||
}
|
||||
}, [pluginRunner, store, webviewLoaded, pluginSettings, reloadCounter]);
|
||||
}, [pluginRunner, store, webviewLoaded, pluginSettings, reloadCounter, blockedPluginVersions]);
|
||||
};
|
||||
|
||||
const useUnloadPluginsOnGlobalDisable = (
|
||||
@@ -95,6 +97,7 @@ interface Props {
|
||||
pluginSupportEnabled: boolean;
|
||||
pluginStates: PluginStates;
|
||||
devPluginPath: string;
|
||||
blockedPluginVersions: BlockedPluginVersions;
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
themeId: number;
|
||||
}
|
||||
@@ -125,7 +128,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
|
||||
}, [webviewReloadCounter]);
|
||||
|
||||
const pluginSettings = usePluginSettings(props.serializedPluginSettings);
|
||||
usePlugins(pluginRunner, webviewLoaded, pluginSettings, props.pluginSupportEnabled, props.devPluginPath);
|
||||
usePlugins(pluginRunner, webviewLoaded, pluginSettings, props.pluginSupportEnabled, props.devPluginPath, props.blockedPluginVersions);
|
||||
useUnloadPluginsOnGlobalDisable(props.pluginStates, props.pluginSupportEnabled);
|
||||
|
||||
const onLoadStart = useCallback(() => {
|
||||
@@ -213,6 +216,7 @@ export default connect((state: AppState) => {
|
||||
serializedPluginSettings: state.settings['plugins.states'],
|
||||
pluginSupportEnabled: state.settings['plugins.pluginSupportEnabled'],
|
||||
devPluginPath: state.settings['plugins.devPluginPaths'],
|
||||
blockedPluginVersions: state.settings['plugins.blockedVersions'],
|
||||
pluginStates: state.pluginService.plugins,
|
||||
pluginHtmlContents: state.pluginService.pluginHtmlContents,
|
||||
themeId: state.settings.theme,
|
||||
|
||||
@@ -7,8 +7,10 @@ import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/
|
||||
import createMockReduxStore from '../../../utils/testing/createMockReduxStore';
|
||||
import setupGlobalStore from '../../../utils/testing/setupGlobalStore';
|
||||
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
import { act, render, screen } from '../../../utils/testing/testingLibrary';
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../utils/testing/testingLibrary';
|
||||
|
||||
let store: Store<AppState>;
|
||||
const WrappedNoteLockConfig: React.FC = () => {
|
||||
@@ -28,19 +30,27 @@ describe('NoteLockConfig', () => {
|
||||
});
|
||||
afterEach(() => {
|
||||
screen.unmount();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Seed the key via save() rather than create(): the mobile test crypto shim can't
|
||||
// run real key generation.
|
||||
const seedKey = () => act(() => {
|
||||
NoteLockKey.instance().save({ id: 'test-note-lock-key' } as MasterKeyEntity);
|
||||
});
|
||||
|
||||
const fillPasswords = (newPassword: string, repeat = newPassword) => {
|
||||
fireEvent.changeText(screen.getByLabelText('New password'), newPassword);
|
||||
fireEvent.changeText(screen.getByLabelText('Repeat password'), repeat);
|
||||
};
|
||||
|
||||
test('should show the password setup form only while no note lock key is set', async () => {
|
||||
const { unmount } = render(<WrappedNoteLockConfig/>);
|
||||
|
||||
expect(screen.getByText('Password setup')).toBeVisible();
|
||||
expect(screen.getByLabelText('Password')).toBeVisible();
|
||||
|
||||
// Seed the key via save() rather than create(): the mobile test crypto shim can't
|
||||
// run real key generation.
|
||||
act(() => {
|
||||
NoteLockKey.instance().save({ id: 'test-note-lock-key' } as MasterKeyEntity);
|
||||
});
|
||||
seedKey();
|
||||
|
||||
expect(screen.queryByText('Password setup')).toBeNull();
|
||||
expect(screen.queryByLabelText('Password')).toBeNull();
|
||||
@@ -48,4 +58,59 @@ describe('NoteLockConfig', () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('should change the password once the current one is given', async () => {
|
||||
seedKey();
|
||||
const changePassword = jest.spyOn(NoteLockKey.instance(), 'changePassword').mockResolvedValue(null);
|
||||
render(<WrappedNoteLockConfig/>);
|
||||
|
||||
fireEvent.press(screen.getByText('Change note lock password'));
|
||||
fillPasswords('new-pass');
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
|
||||
fireEvent.changeText(screen.getByLabelText('Current password'), 'old-pass');
|
||||
fireEvent.press(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => expect(changePassword).toHaveBeenCalledWith('old-pass', 'new-pass'));
|
||||
expect(await screen.findByText('Manage password')).toBeVisible();
|
||||
});
|
||||
|
||||
const webCryptoError = new Error('The operation failed');
|
||||
webCryptoError.name = 'OperationError';
|
||||
// What the native crypto on Android throws for a wrong password.
|
||||
const nativeCryptoError = new Error('Authentication failed! Error: Cipher.final(...): Cipher final failed');
|
||||
nativeCryptoError.name = 'OperationError';
|
||||
|
||||
test.each([webCryptoError, nativeCryptoError])('should report a wrong current password and keep the form (%s)', async (wrongPasswordError) => {
|
||||
seedKey();
|
||||
jest.spyOn(NoteLockKey.instance(), 'changePassword').mockRejectedValue(wrongPasswordError);
|
||||
render(<WrappedNoteLockConfig/>);
|
||||
|
||||
fireEvent.press(screen.getByText('Change note lock password'));
|
||||
fireEvent.changeText(screen.getByLabelText('Current password'), 'wrong-pass');
|
||||
fillPasswords('new-pass');
|
||||
fireEvent.press(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByText('Invalid password')).toBeVisible();
|
||||
expect(screen.getByLabelText('Current password')).toBeVisible();
|
||||
});
|
||||
|
||||
test.each([true, false])('should reset the password only once the warning is confirmed (%s)', async (confirmed) => {
|
||||
seedKey();
|
||||
const showConfirmationDialog = jest.spyOn(shim, 'showConfirmationDialog').mockResolvedValue(confirmed);
|
||||
const reset = jest.spyOn(NoteLockSession.instance(), 'reset').mockResolvedValue(null);
|
||||
render(<WrappedNoteLockConfig/>);
|
||||
|
||||
fireEvent.press(screen.getByText('Forgot password?'));
|
||||
fillPasswords('new-pass');
|
||||
fireEvent.press(screen.getByRole('button', { name: /Reset password/ }));
|
||||
|
||||
await waitFor(() => expect(showConfirmationDialog).toHaveBeenCalled());
|
||||
if (confirmed) {
|
||||
await waitFor(() => expect(reset).toHaveBeenCalledWith('new-pass'));
|
||||
} else {
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('New password')).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { View, Text, TextInput, StyleSheet } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
import { themeStyle } from '../../global-style';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { PrimaryButton } from '../../buttons';
|
||||
import useNoteLockMode, { ActionMode, noteLockPasswordErrorMessage } from '@joplin/lib/components/shared/config/useNoteLockMode';
|
||||
import { PrimaryButton, SecondaryButton } from '../../buttons';
|
||||
import { State } from '@joplin/lib/reducer';
|
||||
|
||||
interface Props {
|
||||
@@ -15,10 +18,27 @@ interface Props {
|
||||
}
|
||||
|
||||
const NoteLockConfig = (props: Props) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordRepeat, setPasswordRepeat] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const hasKey = props.hasNoteLockKey;
|
||||
const {
|
||||
mode,
|
||||
onModeChange,
|
||||
currentPassword,
|
||||
setCurrentPassword,
|
||||
password,
|
||||
setPassword,
|
||||
passwordRepeat,
|
||||
setPasswordRepeat,
|
||||
error,
|
||||
setError,
|
||||
saving,
|
||||
setSaving,
|
||||
passwordMismatch,
|
||||
canSave,
|
||||
forgotPasswordDescription,
|
||||
resetPasswordTitle,
|
||||
passwordReminder,
|
||||
title,
|
||||
} = useNoteLockMode(hasKey);
|
||||
|
||||
const theme = useMemo(() => themeStyle(props.themeId), [props.themeId]);
|
||||
|
||||
@@ -62,78 +82,104 @@ const NoteLockConfig = (props: Props) => {
|
||||
color: theme.color,
|
||||
marginTop: theme.itemMarginTop,
|
||||
},
|
||||
button: {
|
||||
marginBottom: theme.itemMarginBottom,
|
||||
},
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
const hasKey = props.hasNoteLockKey;
|
||||
const passwordMismatch = !!passwordRepeat && password !== passwordRepeat;
|
||||
const canSave = !!password && !!passwordRepeat && !passwordMismatch && !saving;
|
||||
|
||||
const onCreate = useCallback(async () => {
|
||||
const onSave = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
if (mode === ActionMode.Reset && !await shim.showConfirmationDialog(`${_('Warning:')} ${forgotPasswordDescription}\n\n${_('Continue?')}`)) return;
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await NoteLockKey.instance().create(password);
|
||||
setPassword('');
|
||||
setPasswordRepeat('');
|
||||
if (mode === ActionMode.Create) {
|
||||
await NoteLockKey.instance().create(password);
|
||||
} else if (mode === ActionMode.Change) {
|
||||
await NoteLockKey.instance().changePassword(currentPassword, password);
|
||||
} else if (mode === ActionMode.Reset) {
|
||||
await NoteLockSession.instance().reset(password);
|
||||
}
|
||||
onModeChange(ActionMode.Collapsed);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
setError(noteLockPasswordErrorMessage(error, true));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [canSave, password]);
|
||||
}, [canSave, mode, password, currentPassword, forgotPasswordDescription, onModeChange, setError, setSaving]);
|
||||
|
||||
const onCurrentPasswordChange = useCallback((value: string) => {
|
||||
setCurrentPassword(value);
|
||||
setError('');
|
||||
}, [setCurrentPassword, setError]);
|
||||
|
||||
const onPasswordChange = useCallback((value: string) => {
|
||||
setPassword(value);
|
||||
setError('');
|
||||
}, []);
|
||||
}, [setPassword, setError]);
|
||||
|
||||
const onPasswordRepeatChange = useCallback((value: string) => {
|
||||
setPasswordRepeat(value);
|
||||
setError('');
|
||||
}, []);
|
||||
}, [setPasswordRepeat, setError]);
|
||||
|
||||
const passwordLabelId = 'note-lock-password';
|
||||
const repeatLabelId = 'note-lock-password-repeat';
|
||||
const onChangePasswordPress = useCallback(() => {
|
||||
onModeChange(ActionMode.Change);
|
||||
}, [onModeChange]);
|
||||
|
||||
const renderPasswordSetup = () => {
|
||||
if (hasKey) return null;
|
||||
const onForgotPasswordPress = useCallback(() => {
|
||||
onModeChange(ActionMode.Reset);
|
||||
}, [onModeChange]);
|
||||
|
||||
const onCancelPress = useCallback(() => {
|
||||
onModeChange(ActionMode.Collapsed);
|
||||
}, [onModeChange]);
|
||||
|
||||
const renderPasswordInput = (labelId: string, label: string, value: string, onChange: (value: string)=> void, isNewPassword: boolean) => {
|
||||
return <>
|
||||
<Text nativeID={labelId} style={styles.normalText}>{label}</Text>
|
||||
<TextInput
|
||||
accessibilityLabelledBy={labelId}
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
style={styles.passwordInput}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType={isNewPassword ? 'newPassword' : 'password'}
|
||||
importantForAutofill='yes'
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
/>
|
||||
</>;
|
||||
};
|
||||
|
||||
const renderManageButtons = () => {
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.titleText} accessibilityRole='header'>{_('Password setup')}</Text>
|
||||
<Text nativeID={passwordLabelId} style={styles.normalText}>{_('Password')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabelledBy={passwordLabelId}
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
style={styles.passwordInput}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='newPassword'
|
||||
importantForAutofill='yes'
|
||||
value={password}
|
||||
onChangeText={onPasswordChange}
|
||||
/>
|
||||
<Text nativeID={repeatLabelId} style={styles.normalText}>{_('Repeat password')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabelledBy={repeatLabelId}
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
style={styles.passwordInput}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='newPassword'
|
||||
importantForAutofill='yes'
|
||||
value={passwordRepeat}
|
||||
onChangeText={onPasswordRepeatChange}
|
||||
/>
|
||||
<SecondaryButton style={styles.button} onPress={onChangePasswordPress}>{_('Change note lock password')}</SecondaryButton>
|
||||
<SecondaryButton style={styles.button} onPress={onForgotPasswordPress}>{_('Forgot password?')}</SecondaryButton>
|
||||
<Text style={styles.normalText}>{forgotPasswordDescription}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPasswordForm = () => {
|
||||
return (
|
||||
<View>
|
||||
{mode === ActionMode.Reset ? <Text style={styles.errorText}><Text style={styles.boldText}>{_('Warning:')}</Text> {forgotPasswordDescription}</Text> : null}
|
||||
{mode === ActionMode.Change ? renderPasswordInput('note-lock-current-password', _('Current password'), currentPassword, onCurrentPasswordChange, false) : null}
|
||||
{renderPasswordInput('note-lock-password', mode === ActionMode.Create ? _('Password') : _('New password'), password, onPasswordChange, true)}
|
||||
{renderPasswordInput('note-lock-password-repeat', _('Repeat password'), passwordRepeat, onPasswordRepeatChange, true)}
|
||||
{passwordMismatch ? <Text style={styles.errorText}>{_('Passwords do not match')}</Text> : null}
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
<PrimaryButton disabled={!canSave} onPress={onCreate}>{_('Save')}</PrimaryButton>
|
||||
<Text style={styles.reminderText}>{_('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.')}</Text>
|
||||
{mode === ActionMode.Reset ?
|
||||
<SecondaryButton style={styles.button} disabled={!canSave} onPress={onSave}>{resetPasswordTitle}</SecondaryButton> :
|
||||
<PrimaryButton style={styles.button} disabled={!canSave} onPress={onSave}>{_('Save')}</PrimaryButton>}
|
||||
{mode !== ActionMode.Create ? <SecondaryButton disabled={saving} onPress={onCancelPress}>{_('Cancel')}</SecondaryButton> : null}
|
||||
<Text style={styles.reminderText}>{passwordReminder}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -141,7 +187,8 @@ const NoteLockConfig = (props: Props) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.normalText}><Text style={styles.boldText}>{_('Note lock password:')}</Text> {hasKey ? _('Set') : _('Not set')}</Text>
|
||||
{renderPasswordSetup()}
|
||||
<Text style={styles.titleText} accessibilityRole='header'>{title}</Text>
|
||||
{mode === ActionMode.Collapsed ? renderManageButtons() : renderPasswordForm()}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, Text, TextInput, StyleSheet } from 'react-native';
|
||||
import { themeStyle } from '../../global-style';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { noteLockPasswordErrorMessage } from '@joplin/lib/components/shared/config/useNoteLockMode';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import Icon from '../../Icon';
|
||||
@@ -87,9 +88,7 @@ const NoteLockPanel = (props: Props) => {
|
||||
} catch (error) {
|
||||
logger.warn('Could not unlock the note lock session:', error);
|
||||
if (!mountedRef.current) return;
|
||||
// WebCrypto reports a wrong password as a generic OperationError. Other errors stay
|
||||
// out of the UI, e.g. the native crypto backends word theirs differently.
|
||||
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : _('Could not unlock. Please try again.'));
|
||||
setErrorMessage(noteLockPasswordErrorMessage(error));
|
||||
setUnlocking(false);
|
||||
}
|
||||
}, [password, unlocking, props.onUnlocked]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { connect } from 'react-redux';
|
||||
import { Dispatch } from 'redux';
|
||||
import { themeStyle } from '../global-style';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { noteLockPasswordErrorMessage } from '@joplin/lib/components/shared/config/useNoteLockMode';
|
||||
import { confirmAndSkipNoteLockKeyMigration, startNoteLockKeyMigration } from '@joplin/lib/services/noteLock/NoteLockKeyMigration';
|
||||
import ScreenHeader from '../ScreenHeader';
|
||||
import { PrimaryButton, SecondaryButton } from '../buttons';
|
||||
@@ -72,7 +73,7 @@ export const NoteLockMigrationScreenComponent: React.FC<Props> = props => {
|
||||
try {
|
||||
await startNoteLockKeyMigration(localPassword, targetPassword, props.dispatch, () => props.dispatch({ type: 'NAV_BACK' }));
|
||||
} catch (error) {
|
||||
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
|
||||
setErrorMessage(noteLockPasswordErrorMessage(error, true));
|
||||
setMigrating(false);
|
||||
}
|
||||
}, [localPassword, targetPassword, props.dispatch]);
|
||||
|
||||
@@ -52,7 +52,10 @@ const decryptRaw = (data: ArrayBuffer, algorithm: CipherAlgorithm, key: CryptoBu
|
||||
try {
|
||||
return Buffer.concat([decipher.update(encryptedData), decipher.final()]);
|
||||
} catch (error) {
|
||||
throw new Error(`Authentication failed! ${error}`);
|
||||
const authError = new Error(`Authentication failed! ${error}`);
|
||||
// The name WebCrypto gives a failed decryption, so callers can tell a wrong key on both.
|
||||
authError.name = 'OperationError';
|
||||
throw authError;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import ResourceService from '@joplin/lib/services/ResourceService';
|
||||
import KvStore from '@joplin/lib/services/KvStore';
|
||||
import Setting, { AppType, Env } from '@joplin/lib/models/Setting';
|
||||
import PoorManIntervals from '@joplin/lib/PoorManIntervals';
|
||||
import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import refreshBlockedPluginVersions from '@joplin/lib/services/plugins/refreshBlockedPluginVersions';
|
||||
import { parseNotesParent } from '@joplin/lib/reducer';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import { loadKeychainServiceAndSettings } from '@joplin/lib/services/SettingUtils';
|
||||
@@ -515,6 +517,21 @@ const buildStartupTasks = (
|
||||
|
||||
const updatedSettings = pluginService.clearUpdateState(pluginSettings);
|
||||
Setting.setValue('plugins.states', updatedSettings);
|
||||
|
||||
// Running plugins that are now blocked are unloaded by PluginRunnerWebView when the setting changes
|
||||
const repoApi = RepositoryApi.ofDefaultJoplinRepo(
|
||||
Setting.value('tempDir'), { type: AppType.Mobile, version: platformImplementation.versionInfo.version }, InstallMode.Default,
|
||||
);
|
||||
const checkBlockedPlugins = async () => {
|
||||
if (!Setting.value('plugins.pluginSupportEnabled')) return;
|
||||
try {
|
||||
await refreshBlockedPluginVersions(repoApi);
|
||||
} catch (error) {
|
||||
logger.warn('Could not refresh blocked plugin versions:', error);
|
||||
}
|
||||
};
|
||||
void checkBlockedPlugins();
|
||||
PoorManIntervals.setInterval(() => { void checkBlockedPlugins(); }, 60 * 60 * 1000);
|
||||
});
|
||||
addTask('buildStartupTasks/run startup tests', async () => {
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -85,6 +85,9 @@ const buildSharedConfig = (hotReload: boolean): webpack.Configuration => {
|
||||
// Force react-native-paper to use the correct icon library
|
||||
'react-native-vector-icons/MaterialCommunityIcons': throwOnLoadLibraryMock,
|
||||
'@expo/vector-icons/MaterialCommunityIcons': throwOnLoadLibraryMock,
|
||||
// expo-font is optionally used by react-native-vector-icons, but doesn't load correctly on web
|
||||
// (tries to import `node:async_hooks`):
|
||||
'expo-font': throwOnLoadLibraryMock,
|
||||
|
||||
// Workaround for applying serviceworker types to a single file.
|
||||
// See https://joshuatz.com/posts/2021/strongly-typed-service-workers/.
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
built-plugins/
|
||||
plugin-sources/*
|
||||
plugin-sources/*
|
||||
build-cache/
|
||||
@@ -1,9 +1,10 @@
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { copy, exists, remove, readdir, mkdtemp } from 'fs-extra';
|
||||
import { copy, exists, remove, readdir, mkdtemp, readFile } from 'fs-extra';
|
||||
import { join, resolve, basename } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { createHash } from 'crypto';
|
||||
import { chdir, cwd } from 'process';
|
||||
import { execCommand } from '@joplin/utils';
|
||||
import { glob } from 'glob';
|
||||
@@ -16,8 +17,22 @@ interface Options {
|
||||
outputParentDir: string|null;
|
||||
beforeInstall: (buildDir: string, pluginName: string)=> Promise<void>;
|
||||
beforePatch: ()=> Promise<void>;
|
||||
useBuildCache: boolean;
|
||||
}
|
||||
|
||||
// Increment to invalidate cached builds, for example after changing how plugins are built
|
||||
const buildCacheVersion = 1;
|
||||
const buildCacheDir = process.env.JOPLIN_DEFAULT_PLUGINS_CACHE_DIR || join(__dirname, 'build-cache');
|
||||
|
||||
const getBuildCachePath = async (pluginId: string, repositoryData: RepositoryData) => {
|
||||
const patchFile = getPathToPatchFileFor(pluginId);
|
||||
const patch = await exists(patchFile) ? await readFile(patchFile, 'utf8') : '';
|
||||
const hash = createHash('sha256')
|
||||
.update(JSON.stringify([buildCacheVersion, pluginId, repositoryData.cloneUrl, repositoryData.commit, patch]))
|
||||
.digest('hex');
|
||||
return join(buildCacheDir, `${pluginId}-${hash.substring(0, 16)}.jpl`);
|
||||
};
|
||||
|
||||
const buildDefaultPlugins = async (options: Options) => {
|
||||
const pluginRepositoryData = await readRepositoryJson(join(__dirname, 'pluginRepositories.json'));
|
||||
|
||||
@@ -26,7 +41,23 @@ const buildDefaultPlugins = async (options: Options) => {
|
||||
const outputPath = options.outputParentDir && join(options.outputParentDir, `${pluginId}.jpl`);
|
||||
|
||||
if (repositoryData.type === BuiltInPluginType.Built) {
|
||||
await buildPlugin(pluginId, repositoryData, outputPath, options);
|
||||
const cachePath = options.useBuildCache && outputPath ? await getBuildCachePath(pluginId, repositoryData) : null;
|
||||
if (cachePath && await exists(cachePath)) {
|
||||
logStatus('Using cached build of', pluginId, 'from', cachePath);
|
||||
await copy(cachePath, outputPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceHasLocalChanges = await buildPlugin(pluginId, repositoryData, outputPath, options);
|
||||
|
||||
if (cachePath) {
|
||||
if (sourceHasLocalChanges) {
|
||||
logStatus('Not caching build of', pluginId, ': its source checkout has local changes');
|
||||
} else {
|
||||
await copy(outputPath, cachePath);
|
||||
logStatus('Saved build of', pluginId, 'to', cachePath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!outputPath) {
|
||||
console.warn('Skipping NPM plugin,', pluginId, ': missing output path.');
|
||||
@@ -80,6 +111,9 @@ const buildPlugin = async (pluginId: string, repositoryData: RepositoryData, out
|
||||
throw new Error(`Unable to checkout commit ${expectedCommitHash}`);
|
||||
}
|
||||
|
||||
// Local changes are included in the build but not in the build cache key
|
||||
const sourceHasLocalChanges = (await execCommand(['git', 'status', '--porcelain'], { showStdout: false })).trim() !== '';
|
||||
|
||||
logStatus('Copying repository files...');
|
||||
await copy(pluginDir, buildDir, {
|
||||
filter: fileName => {
|
||||
@@ -122,6 +156,8 @@ const buildPlugin = async (pluginId: string, repositoryData: RepositoryData, out
|
||||
} else {
|
||||
console.warn('No output directory specified. Not copying built .jpl files.');
|
||||
}
|
||||
|
||||
return sourceHasLocalChanges;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.log('Build directory', buildDir);
|
||||
|
||||
@@ -5,6 +5,7 @@ const buildAll = (outputDirectory: string) => {
|
||||
outputParentDir: outputDirectory,
|
||||
beforeInstall: async () => { },
|
||||
beforePatch: async () => { },
|
||||
useBuildCache: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const editPatch = async (targetPluginId: string, outputParentDir: string|null) =
|
||||
|
||||
await buildDefaultPlugins({
|
||||
outputParentDir: outputParentDir,
|
||||
useBuildCache: false,
|
||||
beforePatch: async () => {
|
||||
// To make updating just the patch possible, a commit is created just before applying
|
||||
// the patch.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@joplin/fork-htmlparser2",
|
||||
"description": "Fast & forgiving HTML/XML/RSS parser",
|
||||
"version": "4.1.66",
|
||||
"version": "4.1.67",
|
||||
"author": "Felix Boehm <me@feedic.com>",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -18,7 +18,8 @@
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/fb55/htmlparser2.git"
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/fork-htmlparser2"
|
||||
},
|
||||
"directories": {
|
||||
"lib": "lib/"
|
||||
@@ -63,6 +64,5 @@
|
||||
"license": "MIT",
|
||||
"prettier": {
|
||||
"tabWidth": 4
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,17 @@
|
||||
"name": "@joplin/fork-sax",
|
||||
"description": "An evented streaming XML parser in JavaScript",
|
||||
"author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me/)",
|
||||
"version": "1.2.70",
|
||||
"version": "1.2.71",
|
||||
"main": "lib/sax.js",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "ISC",
|
||||
"repository": "git://github.com/isaacs/sax-js.git",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/fork-sax"
|
||||
},
|
||||
"files": [
|
||||
"lib/sax.js",
|
||||
"LICENSE",
|
||||
@@ -17,6 +21,5 @@
|
||||
"devDependencies": {
|
||||
"standard": "17.1.2",
|
||||
"tap": "16.3.10"
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/fork-uslug",
|
||||
"version": "2.0.9",
|
||||
"version": "2.0.10",
|
||||
"description": "A permissive slug generator that works with unicode.",
|
||||
"author": "Jeremy Selier <jerem.selier@gmail.com>",
|
||||
"publishConfig": {
|
||||
@@ -23,7 +23,8 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "http://github.com/jeremys/uslug.git"
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/fork-uslug"
|
||||
},
|
||||
"main": "./index",
|
||||
"exports": {
|
||||
@@ -45,6 +46,5 @@
|
||||
"type": "MIT",
|
||||
"url": "http://github.com/jeremys/uslug/raw/master/LICENSE"
|
||||
}
|
||||
],
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
{
|
||||
"name": "@joplin/htmlpack",
|
||||
"version": "3.7.3",
|
||||
"version": "3.7.4",
|
||||
"description": "Pack an HTML file and all its linked resources into a single HTML file",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/htmlpack"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "index.ts",
|
||||
"exports": {
|
||||
@@ -30,7 +35,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "4.5.0",
|
||||
"@joplin/fork-htmlparser2": "^4.1.66",
|
||||
"@joplin/fork-htmlparser2": "^4.1.67",
|
||||
"datauri": "4.1.0",
|
||||
"fs-extra": "11.3.6",
|
||||
"html-entities": "1.4.0"
|
||||
@@ -40,6 +45,5 @@
|
||||
"@types/jest": "29.5.14",
|
||||
"jest": "29.7.0",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import shim from '../../../shim';
|
||||
import { _ } from '../../../locale';
|
||||
const { useCallback, useEffect, useState } = shim.react();
|
||||
|
||||
// WebCrypto and the mobile native crypto both report a wrong password as an OperationError. Any other
|
||||
// error gets a generic message rather than the raw one, unless the caller's errors are meant for the
|
||||
// user, like the key migration ones.
|
||||
export const noteLockPasswordErrorMessage = (error: unknown, preserveMessage = false) => {
|
||||
if (error instanceof Error && error.name === 'OperationError') return _('Invalid password');
|
||||
if (preserveMessage && error instanceof Error) return error.message;
|
||||
return _('Could not unlock. Please try again.');
|
||||
};
|
||||
|
||||
export enum ActionMode {
|
||||
Collapsed = 'collapsed',
|
||||
Create = 'create',
|
||||
Change = 'change',
|
||||
Reset = 'reset',
|
||||
}
|
||||
|
||||
const useNoteLockMode = (hasKey: boolean) => {
|
||||
const [mode, setMode] = useState<ActionMode>(hasKey ? ActionMode.Collapsed : ActionMode.Create);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordRepeat, setPasswordRepeat] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const clearForm = useCallback(() => {
|
||||
setCurrentPassword('');
|
||||
setPassword('');
|
||||
setPasswordRepeat('');
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const onModeChange = useCallback((newMode: ActionMode) => {
|
||||
setMode(newMode);
|
||||
clearForm();
|
||||
}, [clearForm]);
|
||||
|
||||
// The key can also appear or disappear through sync while this screen is open;
|
||||
// clear any drafted input along with the mode switch.
|
||||
useEffect(() => {
|
||||
if (hasKey && mode === ActionMode.Create) onModeChange(ActionMode.Collapsed);
|
||||
if (!hasKey && mode !== ActionMode.Create) onModeChange(ActionMode.Create);
|
||||
}, [hasKey, mode, onModeChange]);
|
||||
|
||||
const passwordMismatch = !!passwordRepeat && password !== passwordRepeat;
|
||||
const canSave = !!password && !!passwordRepeat && !passwordMismatch && !saving && (mode !== ActionMode.Change || !!currentPassword);
|
||||
|
||||
const forgotPasswordDescription = _('Only do this if you\'ve forgotten your current password. A new password will be created, and any notes locked with the old one will become permanently unreadable.');
|
||||
const resetPasswordTitle = `⚠️ ${_('Reset password')} ⚠️`;
|
||||
// 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.');
|
||||
|
||||
let title = _('Password setup');
|
||||
if (mode === ActionMode.Reset) title = resetPasswordTitle;
|
||||
if (mode === ActionMode.Change) title = _('Change note lock password');
|
||||
if (mode === ActionMode.Collapsed) title = _('Manage password');
|
||||
|
||||
return {
|
||||
mode,
|
||||
onModeChange,
|
||||
currentPassword,
|
||||
setCurrentPassword,
|
||||
password,
|
||||
setPassword,
|
||||
passwordRepeat,
|
||||
setPasswordRepeat,
|
||||
error,
|
||||
setError,
|
||||
saving,
|
||||
setSaving,
|
||||
passwordMismatch,
|
||||
canSave,
|
||||
forgotPasswordDescription,
|
||||
resetPasswordTitle,
|
||||
passwordReminder,
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
export default useNoteLockMode;
|
||||
@@ -683,7 +683,9 @@ export default class Resource extends BaseItem {
|
||||
options = { ...options, autoTimestamp: false };
|
||||
if (!resource.created_time) resource.created_time = now;
|
||||
if (!resource.updated_time) resource.updated_time = now;
|
||||
if (!resource.blob_updated_time) resource.blob_updated_time = now;
|
||||
// Resources synced by older clients have no blob_updated_time. Deriving it from
|
||||
// updated_time, like migration 44 does, keeps it identical on every client.
|
||||
if (!resource.blob_updated_time) resource.blob_updated_time = resource.updated_time;
|
||||
}
|
||||
|
||||
const output = await super.save(resource, options);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type SettingType from '../Setting';
|
||||
import { AppType, SettingItemSubType, SettingItemType, SettingStorage, SyncStartupOperation, SettingItem, SettingButtonStyle } from './types';
|
||||
import { defaultListColumns } from '../../services/plugins/api/noteListType';
|
||||
import type { PluginSettings } from '../../services/plugins/PluginService';
|
||||
import type { BlockedPluginVersions } from '../../services/plugins/utils/blockedVersions';
|
||||
import type { PublicPrivateKeyPair } from '../../services/e2ee/ppk/ppk';
|
||||
import type { NoteLockKeyConflict } from '../../services/synchronizer/syncInfoUtils';
|
||||
import { EmptyObject } from '@joplin/utils/types';
|
||||
@@ -1447,6 +1448,20 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
autoSave: true,
|
||||
},
|
||||
|
||||
'plugins.blockedVersions': {
|
||||
value: {} as BlockedPluginVersions,
|
||||
type: SettingItemType.Object,
|
||||
public: false,
|
||||
appTypes: [AppType.Desktop, AppType.Mobile],
|
||||
},
|
||||
|
||||
'plugins.blockedRestartNotice': {
|
||||
value: [] as string[],
|
||||
type: SettingItemType.Array,
|
||||
public: false,
|
||||
appTypes: [AppType.Desktop],
|
||||
},
|
||||
|
||||
'plugins.enableWebviewDebugging': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
|
||||
+16
-12
@@ -1,7 +1,12 @@
|
||||
{
|
||||
"name": "@joplin/lib",
|
||||
"version": "3.7.3",
|
||||
"version": "3.7.4",
|
||||
"description": "Joplin Core library",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/lib"
|
||||
},
|
||||
"author": "Laurent Cozic",
|
||||
"homepage": "",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -50,15 +55,15 @@
|
||||
"@adobe/css-tools": "4.5.0",
|
||||
"@aws-sdk/client-s3": "3.296.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.296.0",
|
||||
"@joplin/fork-htmlparser2": "^4.1.66",
|
||||
"@joplin/fork-sax": "^1.2.70",
|
||||
"@joplin/fork-uslug": "^2.0.9",
|
||||
"@joplin/htmlpack": "^3.7.3",
|
||||
"@joplin/onenote-converter": "^3.7.3",
|
||||
"@joplin/renderer": "^3.7.3",
|
||||
"@joplin/turndown": "^4.0.88",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.70",
|
||||
"@joplin/utils": "^3.7.3",
|
||||
"@joplin/fork-htmlparser2": "^4.1.67",
|
||||
"@joplin/fork-sax": "^1.2.71",
|
||||
"@joplin/fork-uslug": "^2.0.10",
|
||||
"@joplin/htmlpack": "^3.7.4",
|
||||
"@joplin/onenote-converter": "^3.7.4",
|
||||
"@joplin/renderer": "^3.7.4",
|
||||
"@joplin/turndown": "^4.0.89",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.71",
|
||||
"@joplin/utils": "^3.7.4",
|
||||
"adm-zip": "0.6.0",
|
||||
"async-mutex": "0.5.0",
|
||||
"base-64": "1.0.0",
|
||||
@@ -108,6 +113,5 @@
|
||||
"uuid": "11.1.1",
|
||||
"word-wrap": "1.2.5",
|
||||
"xml2js": "0.4.23"
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,7 @@ export interface State extends WindowState {
|
||||
isInsertingNotes: boolean;
|
||||
hasEncryptedItems: boolean;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
noteLockBulkChangeRunning: boolean;
|
||||
noteLockMigrationStatus: NoteLockMigrationStatus|null;
|
||||
needApiAuth: boolean;
|
||||
profileConfig: ProfileConfig;
|
||||
@@ -272,6 +273,7 @@ export const defaultState: State = {
|
||||
isInsertingNotes: false,
|
||||
hasEncryptedItems: false,
|
||||
noteLockSessionUnlocked: false,
|
||||
noteLockBulkChangeRunning: false,
|
||||
noteLockMigrationStatus: null,
|
||||
needApiAuth: false,
|
||||
profileConfig: null,
|
||||
@@ -1591,6 +1593,10 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
|
||||
draft.noteLockSessionUnlocked = action.value;
|
||||
break;
|
||||
|
||||
case 'NOTE_LOCK_BULK_CHANGE_RUNNING_SET':
|
||||
draft.noteLockBulkChangeRunning = action.value;
|
||||
break;
|
||||
|
||||
case 'NOTE_LOCK_MIGRATION_STATUS_SET':
|
||||
draft.noteLockMigrationStatus = action.value;
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import Note from '../../models/Note';
|
||||
import Folder from '../../models/Folder';
|
||||
import { NoteEntity } from '../database/types';
|
||||
import { db, encryptionService, setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv } from '../../testing/test-utils';
|
||||
import shim from '../../shim';
|
||||
import eventManager, { EventName } from '../../eventManager';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import NoteLockKey from './NoteLockKey';
|
||||
import NoteLockService from './NoteLockService';
|
||||
import NoteLockSession from './NoteLockSession';
|
||||
import bulkSetNoteLockState, { folderTreeNoteIds, promptAndBulkSetNoteLockState } from './bulkSetNoteLockState';
|
||||
|
||||
const setUpUnlockedSession = async (password = '123456') => {
|
||||
await NoteLockKey.instance().create(password);
|
||||
await NoteLockSession.instance().unlock(password);
|
||||
};
|
||||
|
||||
const createLockedNote = async (props: NoteEntity) => {
|
||||
return Note.save({ ...props, is_locked: 1 }, { useNoteLock: true });
|
||||
};
|
||||
|
||||
// What a sync leaves behind when the sync target uses a different note lock key.
|
||||
const simulateMigrationNeeded = () => {
|
||||
Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: { id: 'target-key' }, syncMigrationId: 'target-migration' });
|
||||
};
|
||||
|
||||
const prompts = { promptPassword: async () => '123456', goToNoteLockSetup: jest.fn() };
|
||||
|
||||
describe('bulkSetNoteLockState', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
NoteLockService.destroyInstance();
|
||||
NoteLockSession.destroyInstance();
|
||||
NoteLockKey.destroyInstance();
|
||||
EncryptionService.instance_ = encryptionService();
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
jest.spyOn(shim, 'showMessageBox').mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
Setting.setValue('noteLock.conflictNoteLockKey', {});
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should lock the notes in a notebook and its sub-notebooks', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const subFolder = await Folder.save({ title: 'sub', parent_id: folder.id });
|
||||
const otherFolder = await Folder.save({ title: 'other' });
|
||||
const note1 = await Note.save({ title: 'one', body: 'secret one', parent_id: folder.id });
|
||||
const note2 = await Note.save({ title: 'two', body: 'secret two', parent_id: subFolder.id });
|
||||
const alreadyLocked = await createLockedNote({ title: 'locked', body: 'secret locked', parent_id: folder.id });
|
||||
const otherNote = await Note.save({ title: 'out', body: 'out of scope', parent_id: otherFolder.id });
|
||||
|
||||
const result = await bulkSetNoteLockState(await folderTreeNoteIds(folder.id), true);
|
||||
|
||||
expect(result).toEqual({ changed: 2, skipped: 1, failed: 0 });
|
||||
for (const id of [note1.id, note2.id, alreadyLocked.id]) {
|
||||
const note = await Note.load(id);
|
||||
expect(note.is_locked).toBe(1);
|
||||
expect(note.body).not.toContain('secret');
|
||||
}
|
||||
expect((await Note.load(note1.id, { useNoteLock: true })).body).toBe('secret one');
|
||||
expect((await Note.load(otherNote.id)).is_locked).toBe(0);
|
||||
});
|
||||
|
||||
it('should remove the note lock from the selected notes', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note1 = await createLockedNote({ title: 'one', body: 'secret one', parent_id: folder.id });
|
||||
const note2 = await createLockedNote({ title: 'two', body: 'secret two', parent_id: folder.id });
|
||||
const notSelected = await createLockedNote({ title: 'three', body: 'secret three', parent_id: folder.id });
|
||||
const plainNote = await Note.save({ title: 'plain', body: 'already plain', parent_id: folder.id });
|
||||
|
||||
const result = await bulkSetNoteLockState([note1.id, note2.id, plainNote.id], false);
|
||||
|
||||
expect(result).toEqual({ changed: 2, skipped: 1, failed: 0 });
|
||||
expect(await Note.load(note1.id)).toMatchObject({ is_locked: 0, body: 'secret one' });
|
||||
expect(await Note.load(note2.id)).toMatchObject({ is_locked: 0, body: 'secret two' });
|
||||
expect((await Note.load(notSelected.id)).is_locked).toBe(1);
|
||||
});
|
||||
|
||||
it('should skip notes in a read-only share', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const sharedNote = await Note.save({ title: 'shared', body: 'shared body', parent_id: folder.id, share_id: 'share-1' });
|
||||
const cleanup = simulateReadOnlyShareEnv('share-1');
|
||||
try {
|
||||
const result = await bulkSetNoteLockState([sharedNote.id], true);
|
||||
expect(result).toEqual({ changed: 0, skipped: 1, failed: 0 });
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
expect((await Note.load(sharedNote.id)).is_locked).toBe(0);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['lock', false, true],
|
||||
['unlock', true, false],
|
||||
])('should still %s the remaining notes when the session locks mid-run', async (_label, startLocked, isLocked) => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const makeNote = (props: NoteEntity) => startLocked ? createLockedNote(props) : Note.save(props);
|
||||
const note1 = await makeNote({ title: 'one', body: 'secret one', parent_id: folder.id });
|
||||
const note2 = await makeNote({ title: 'two', body: 'secret two', parent_id: folder.id });
|
||||
|
||||
const save = Note.save.bind(Note);
|
||||
jest.spyOn(Note, 'save').mockImplementation(async (note, options) => {
|
||||
const saved = await save(note, options);
|
||||
NoteLockSession.instance().lock();
|
||||
return saved;
|
||||
});
|
||||
|
||||
expect(await bulkSetNoteLockState([note1.id, note2.id], isLocked)).toEqual({ changed: 2, skipped: 0, failed: 0 });
|
||||
for (const id of [note1.id, note2.id]) {
|
||||
expect((await Note.load(id)).is_locked).toBe(isLocked ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should count a note that fails and still change the others', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
// A null byte in the plaintext body makes the unlocked save fail validation.
|
||||
const badNote = await createLockedNote({ title: 'bad', body: `bad${String.fromCharCode(0)}body`, parent_id: folder.id });
|
||||
const goodNote = await createLockedNote({ title: 'good', body: 'good body', parent_id: folder.id });
|
||||
|
||||
expect(await bulkSetNoteLockState([badNote.id, goodNote.id], false)).toEqual({ changed: 1, skipped: 0, failed: 1 });
|
||||
expect((await Note.load(badNote.id)).is_locked).toBe(1);
|
||||
expect(await Note.load(goodNote.id)).toMatchObject({ is_locked: 0, body: 'good body' });
|
||||
});
|
||||
|
||||
test.each([
|
||||
['lock', 'after', false, true],
|
||||
['unlock', 'after', true, false],
|
||||
['lock', 'during', false, true],
|
||||
['unlock', 'during', true, false],
|
||||
])('should stop when the feature is turned off mid-run (%s, %s the load)', async (_label, timing, startLocked, isLocked) => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const makeNote = (props: NoteEntity) => startLocked ? createLockedNote(props) : Note.save(props);
|
||||
const note1 = await makeNote({ title: 'one', body: 'secret one', parent_id: folder.id });
|
||||
const note2 = await makeNote({ title: 'two', body: 'secret two', parent_id: folder.id });
|
||||
const before = await Note.load(note1.id);
|
||||
|
||||
const load = Note.load.bind(Note);
|
||||
jest.spyOn(Note, 'load').mockImplementation(async (id, options) => {
|
||||
const turnOff = () => { if (options?.useNoteLock) Setting.setValue('featureFlag.noteLock', false); };
|
||||
if (timing === 'during') turnOff();
|
||||
const note = await load(id, options);
|
||||
if (timing === 'after') turnOff();
|
||||
return note;
|
||||
});
|
||||
|
||||
await expect(bulkSetNoteLockState([note1.id, note2.id], isLocked)).rejects.toThrow('cancelled because the note lock feature was disabled');
|
||||
jest.restoreAllMocks();
|
||||
expect(await Note.load(note1.id)).toMatchObject({ is_locked: before.is_locked, body: before.body });
|
||||
expect((await Note.load(note2.id)).is_locked).toBe(startLocked ? 1 : 0);
|
||||
});
|
||||
|
||||
it('should notify an open editor of each change', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const note = await Note.save({ title: 'one', body: 'secret one' });
|
||||
const listener = jest.fn();
|
||||
eventManager.on(EventName.NoteLockNoteStateChange, listener);
|
||||
try {
|
||||
await bulkSetNoteLockState([note.id], true);
|
||||
} finally {
|
||||
eventManager.off(EventName.NoteLockNoteStateChange, listener);
|
||||
}
|
||||
expect(listener).toHaveBeenCalledWith({ noteId: note.id, isLocked: true });
|
||||
});
|
||||
|
||||
it('should fail closed when the session is locked', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const note = await Note.save({ title: 'one', body: 'secret' });
|
||||
NoteLockSession.instance().lock();
|
||||
await expect(bulkSetNoteLockState([note.id], true)).rejects.toThrow();
|
||||
expect((await Note.load(note.id)).is_locked).toBe(0);
|
||||
});
|
||||
|
||||
it('should change nothing when the password prompt is cancelled', async () => {
|
||||
await NoteLockKey.instance().create('123456');
|
||||
const note = await Note.save({ title: 'one', body: 'secret' });
|
||||
const beforeChange = jest.fn();
|
||||
|
||||
await promptAndBulkSetNoteLockState([note.id], true, { promptPassword: async () => null, goToNoteLockSetup: jest.fn() }, jest.fn(), { beforeChange });
|
||||
|
||||
expect((await Note.load(note.id)).is_locked).toBe(0);
|
||||
expect(beforeChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should unlock the session and report the notes that failed', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const badNote = await createLockedNote({ title: 'bad', body: `bad${String.fromCharCode(0)}body` });
|
||||
const goodNote = await createLockedNote({ title: 'good', body: 'good body' });
|
||||
NoteLockSession.instance().lock();
|
||||
const showErrorDialog = jest.spyOn(shim, 'showErrorDialog').mockResolvedValue(undefined);
|
||||
|
||||
await promptAndBulkSetNoteLockState([badNote.id, goodNote.id], false, prompts, jest.fn());
|
||||
|
||||
expect((await Note.load(goodNote.id)).is_locked).toBe(0);
|
||||
expect(showErrorDialog).toHaveBeenCalledWith('The note lock could not be removed from 1 note.');
|
||||
});
|
||||
|
||||
it('should show the in-progress banner only while the notes change', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const note = await Note.save({ title: 'one', body: 'secret' });
|
||||
const dispatch = jest.fn();
|
||||
let bannerDuringSave: boolean = null;
|
||||
const save = Note.save.bind(Note);
|
||||
jest.spyOn(Note, 'save').mockImplementation(async (n, options) => {
|
||||
bannerDuringSave = dispatch.mock.calls[dispatch.mock.calls.length - 1][0].value;
|
||||
return save(n, options);
|
||||
});
|
||||
|
||||
await promptAndBulkSetNoteLockState([note.id], true, prompts, dispatch);
|
||||
|
||||
expect(bannerDuringSave).toBe(true);
|
||||
expect(dispatch).toHaveBeenLastCalledWith({ type: 'NOTE_LOCK_BULK_CHANGE_RUNNING_SET', value: false });
|
||||
expect((await Note.load(note.id)).is_locked).toBe(1);
|
||||
});
|
||||
|
||||
it('should refuse a second run while one is going', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const note1 = await Note.save({ title: 'one', body: 'secret one' });
|
||||
const note2 = await Note.save({ title: 'two', body: 'secret two' });
|
||||
const showErrorDialog = jest.spyOn(shim, 'showErrorDialog').mockResolvedValue(undefined);
|
||||
let secondRun: Promise<void> = null;
|
||||
const save = Note.save.bind(Note);
|
||||
const saveSpy = jest.spyOn(Note, 'save').mockImplementation(async (n, options) => {
|
||||
secondRun ??= promptAndBulkSetNoteLockState([note2.id], true, prompts, jest.fn());
|
||||
return save(n, options);
|
||||
});
|
||||
|
||||
await promptAndBulkSetNoteLockState([note1.id], true, prompts, jest.fn());
|
||||
await secondRun;
|
||||
saveSpy.mockRestore();
|
||||
|
||||
expect(showErrorDialog).toHaveBeenCalledWith('Please wait until the current note lock change has finished.');
|
||||
expect((await Note.load(note1.id)).is_locked).toBe(1);
|
||||
expect((await Note.load(note2.id)).is_locked).toBe(0);
|
||||
|
||||
await promptAndBulkSetNoteLockState([note2.id], true, prompts, jest.fn());
|
||||
expect((await Note.load(note2.id)).is_locked).toBe(1);
|
||||
});
|
||||
|
||||
it('should refuse to start while a migration is running, before asking for the password', async () => {
|
||||
await NoteLockKey.instance().create('123456');
|
||||
const note = await Note.save({ title: 'one', body: 'secret' });
|
||||
NoteLockSession.instance().setMigrating(true);
|
||||
const promptPassword = jest.fn();
|
||||
const showErrorDialog = jest.spyOn(shim, 'showErrorDialog').mockResolvedValue(undefined);
|
||||
|
||||
await promptAndBulkSetNoteLockState([note.id], true, { promptPassword, goToNoteLockSetup: jest.fn() }, jest.fn());
|
||||
|
||||
expect(promptPassword).not.toHaveBeenCalled();
|
||||
expect(showErrorDialog).toHaveBeenCalledWith('The note lock cannot be changed while a migration is in progress.');
|
||||
expect((await Note.load(note.id)).is_locked).toBe(0);
|
||||
});
|
||||
|
||||
it('should refuse to start while a migration is needed, before asking for the password', async () => {
|
||||
await NoteLockKey.instance().create('123456');
|
||||
const note = await Note.save({ title: 'one', body: 'secret' });
|
||||
simulateMigrationNeeded();
|
||||
const promptPassword = jest.fn();
|
||||
const showErrorDialog = jest.spyOn(shim, 'showErrorDialog').mockResolvedValue(undefined);
|
||||
|
||||
await promptAndBulkSetNoteLockState([note.id], true, { promptPassword, goToNoteLockSetup: jest.fn() }, jest.fn());
|
||||
|
||||
expect(promptPassword).not.toHaveBeenCalled();
|
||||
expect(showErrorDialog).toHaveBeenCalledWith('The note lock cannot be changed while your locked notes need to be migrated to the synced key.');
|
||||
expect((await Note.load(note.id)).is_locked).toBe(0);
|
||||
});
|
||||
|
||||
it('should cancel the remaining notes once a migration is needed', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const note1 = await Note.save({ title: 'one', body: 'secret one' });
|
||||
const note2 = await Note.save({ title: 'two', body: 'secret two' });
|
||||
const save = Note.save.bind(Note);
|
||||
jest.spyOn(Note, 'save').mockImplementation(async (n, options) => {
|
||||
const saved = await save(n, options);
|
||||
simulateMigrationNeeded();
|
||||
return saved;
|
||||
});
|
||||
|
||||
await expect(bulkSetNoteLockState([note1.id, note2.id], true)).rejects.toThrow('need to be migrated');
|
||||
jest.restoreAllMocks();
|
||||
|
||||
expect((await Note.load(note1.id)).is_locked).toBe(1);
|
||||
expect((await Note.load(note2.id)).is_locked).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip a note locked with another key when locking, and still lock the rest', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const otherKeyNote = await createLockedNote({ title: 'other key', body: 'secret', parent_id: folder.id });
|
||||
// As after importing a locked note from another profile without updating it to this key.
|
||||
await db().exec('UPDATE notes SET body = ? WHERE id = ?', ['JLD01undecryptable', otherKeyNote.id]);
|
||||
const plainNote = await Note.save({ title: 'plain', body: 'plain body', parent_id: folder.id });
|
||||
|
||||
expect(await bulkSetNoteLockState(await folderTreeNoteIds(folder.id), true)).toEqual({ changed: 1, skipped: 1, failed: 0 });
|
||||
expect((await Note.load(plainNote.id)).is_locked).toBe(1);
|
||||
expect((await Note.load(otherKeyNote.id)).body).toBe('JLD01undecryptable');
|
||||
});
|
||||
|
||||
it('should skip a note locked with another key in a read-only share when removing the lock', async () => {
|
||||
await setUpUnlockedSession();
|
||||
const sharedNote = await createLockedNote({ title: 'shared', body: 'secret', share_id: 'share-1' });
|
||||
// As for a note locked by the share owner, which this key cannot decrypt.
|
||||
await db().exec('UPDATE notes SET body = ? WHERE id = ?', ['JLD01undecryptable', sharedNote.id]);
|
||||
const cleanup = simulateReadOnlyShareEnv('share-1');
|
||||
try {
|
||||
expect(await bulkSetNoteLockState([sharedNote.id], false)).toEqual({ changed: 0, skipped: 1, failed: 0 });
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
['Yes', 0, true],
|
||||
['No', 1, false],
|
||||
])('should lock a whole notebook only when %s is chosen in the confirmation', async (_answer, response, locked) => {
|
||||
await NoteLockKey.instance().create('123456');
|
||||
const note = await Note.save({ title: 'one', body: 'secret' });
|
||||
const showMessageBox = jest.spyOn(shim, 'showMessageBox').mockResolvedValue(response);
|
||||
const promptPassword = jest.fn(async () => '123456');
|
||||
|
||||
await promptAndBulkSetNoteLockState([note.id], true, { promptPassword, goToNoteLockSetup: jest.fn() }, jest.fn(), { wholeNotebook: true });
|
||||
|
||||
expect(showMessageBox.mock.calls[0][0]).toBe('Are you sure you want to lock all notes in this notebook and its nested notebooks?');
|
||||
expect(showMessageBox.mock.calls[0][1].buttons).toEqual(['Yes', 'No']);
|
||||
// The question comes before the password, so No asks for nothing.
|
||||
expect(promptPassword).toHaveBeenCalledTimes(locked ? 1 : 0);
|
||||
expect((await Note.load(note.id)).is_locked).toBe(locked ? 1 : 0);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[true, 'Are you sure you want to lock the selected notes?'],
|
||||
[false, 'Are you sure you want to remove lock from the selected notes?'],
|
||||
])('should confirm before changing a selection of notes (lock: %s)', async (isLocked, question) => {
|
||||
await setUpUnlockedSession();
|
||||
const note = isLocked ? await Note.save({ title: 'one', body: 'secret' }) : await createLockedNote({ title: 'one', body: 'secret' });
|
||||
const showMessageBox = jest.spyOn(shim, 'showMessageBox').mockResolvedValue(1);
|
||||
|
||||
await promptAndBulkSetNoteLockState([note.id], isLocked, prompts, jest.fn());
|
||||
|
||||
expect(showMessageBox.mock.calls[0][0]).toBe(question);
|
||||
expect((await Note.load(note.id)).is_locked).toBe(isLocked ? 0 : 1);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Dispatch } from 'redux';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { _, _n } from '../../locale';
|
||||
import shim, { MessageBoxType } from '../../shim';
|
||||
import Folder from '../../models/Folder';
|
||||
import Note from '../../models/Note';
|
||||
import eventManager, { EventName } from '../../eventManager';
|
||||
import { noteLockKeyConflict } from '../synchronizer/syncInfoUtils';
|
||||
import isNoteLockEnabled from './isNoteLockEnabled';
|
||||
import NoteLockSession from './NoteLockSession';
|
||||
import { NoteLockNoteEntity } from './NoteLockNote';
|
||||
import { NoteLockPrompts, unlockNoteLockSession } from './noteLockPrompts';
|
||||
import { noteLockChangeBlockedReason, validationFields } from './setNoteLockState';
|
||||
|
||||
const logger = Logger.create('bulkSetNoteLockState');
|
||||
|
||||
export const folderTreeNoteIds = async (folderId: string) => {
|
||||
const folderIds = [folderId].concat((await Folder.allChildrenFolders(folderId)).map(folder => folder.id));
|
||||
let noteIds: string[] = [];
|
||||
for (const id of folderIds) noteIds = noteIds.concat(await Folder.noteIds(id));
|
||||
return noteIds;
|
||||
};
|
||||
|
||||
// A migration re-encrypts the notes locked with the local key, so a note locked with it once the migration is needed, or while
|
||||
// one runs (a re-encryption needs no key conflict), would be left behind.
|
||||
const throwIfMigrationNeeded = () => {
|
||||
if (noteLockKeyConflict()) throw new Error(_('The note lock cannot be changed while your locked notes need to be migrated to the synced key.'));
|
||||
if (NoteLockSession.instance().isMigrating()) throw new Error(_('The note lock cannot be changed while a migration is in progress.'));
|
||||
};
|
||||
|
||||
// Unlike the single-note flow in setNoteLockState, this saves the notes itself, since most of them are not
|
||||
// open in an editor. The event still goes out so that an editor which has one of them open picks up the new state.
|
||||
const bulkSetNoteLockState = async (noteIds: string[], isLocked: boolean) => {
|
||||
if (!isNoteLockEnabled()) throw new Error('Note lock is not enabled');
|
||||
// Captured once so that locking the session mid-run does not fail the remaining notes. Throws if the session is locked.
|
||||
const noteLockKey = NoteLockSession.instance().decryptedKey();
|
||||
|
||||
const result = { changed: 0, skipped: 0, failed: 0 };
|
||||
for (const noteId of noteIds) {
|
||||
throwIfMigrationNeeded();
|
||||
try {
|
||||
// Checked before the body is decrypted, so a blocked note, or one locked with another key, is skipped rather than failed.
|
||||
const noteInfo = await Note.load(noteId, { fields: [...validationFields, 'encryption_applied'] });
|
||||
if (!noteInfo || !!noteInfo.is_locked === isLocked || noteInfo.encryption_applied || noteLockChangeBlockedReason(noteInfo)) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
const note: NoteLockNoteEntity = await Note.load(noteId, { useNoteLock: true, noteLockKey });
|
||||
// Without the marker the load was not gated, as when the feature is turned off during it, so the body
|
||||
// would be saved back under the wrong lock state.
|
||||
if (!note.isDecrypted) throw new Error('The note was loaded without the note lock gate');
|
||||
// Validated like a save from the note screen, so a body that cannot be stored is reported rather than written.
|
||||
await Note.save({ ...note, is_locked: isLocked ? 1 : 0 }, { useNoteLock: true, noteLockKey, userSideValidation: true });
|
||||
eventManager.emit(EventName.NoteLockNoteStateChange, { noteId, isLocked });
|
||||
result.changed++;
|
||||
} catch (error) {
|
||||
logger.warn(`Could not change the note lock of note ${noteId}:`, error);
|
||||
result.failed++;
|
||||
}
|
||||
// With the feature turned off, Note.save refuses the change, so the remaining notes would all fail too.
|
||||
if (!isNoteLockEnabled()) throw new Error(_('Note lock changes were cancelled because the note lock feature was disabled.'));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
let running = false;
|
||||
|
||||
interface BulkNoteLockOptions {
|
||||
// Only changes the confirmation wording, since a whole notebook also takes in its nested notebooks.
|
||||
wholeNotebook?: boolean;
|
||||
// Runs once the session is unlocked, for the work that must happen just before the notes change.
|
||||
beforeChange?: ()=> Promise<void>;
|
||||
}
|
||||
|
||||
export const promptAndBulkSetNoteLockState = async (noteIds: string[], isLocked: boolean, prompts: NoteLockPrompts, dispatch: Dispatch, options: BulkNoteLockOptions = {}) => {
|
||||
if (running) {
|
||||
await shim.showErrorDialog(_('Please wait until the current note lock change has finished.'));
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
let message = '';
|
||||
try {
|
||||
throwIfMigrationNeeded();
|
||||
// Locking a note deletes its history, so a change by accident is confirmed first.
|
||||
let question;
|
||||
if (options.wholeNotebook) {
|
||||
question = isLocked
|
||||
? _('Are you sure you want to lock all notes in this notebook and its nested notebooks?')
|
||||
: _('Are you sure you want to remove lock from all notes in this notebook and its nested notebooks?');
|
||||
} else {
|
||||
question = isLocked ? _('Are you sure you want to lock the selected notes?') : _('Are you sure you want to remove lock from the selected notes?');
|
||||
}
|
||||
if (await shim.showMessageBox(question, { buttons: [_('Yes'), _('No')], type: MessageBoxType.Confirm, cancelId: 1 }) !== 0) return;
|
||||
if (!await unlockNoteLockSession(prompts)) return;
|
||||
await options.beforeChange?.();
|
||||
dispatch({ type: 'NOTE_LOCK_BULK_CHANGE_RUNNING_SET', value: true });
|
||||
const { failed } = await bulkSetNoteLockState(noteIds, isLocked);
|
||||
if (failed) {
|
||||
message = isLocked
|
||||
? _n('%d note could not be locked.', '%d notes could not be locked.', failed, failed)
|
||||
: _n('The note lock could not be removed from %d note.', 'The note lock could not be removed from %d notes.', failed, failed);
|
||||
}
|
||||
} catch (error) {
|
||||
message = error.message;
|
||||
} finally {
|
||||
running = false;
|
||||
dispatch({ type: 'NOTE_LOCK_BULK_CHANGE_RUNNING_SET', value: false });
|
||||
}
|
||||
if (message) await shim.showErrorDialog(message);
|
||||
};
|
||||
|
||||
export default bulkSetNoteLockState;
|
||||
@@ -13,20 +13,27 @@ import JoplinError from '../../JoplinError';
|
||||
import { ErrorCode } from '../../errors';
|
||||
import { _ } from '../../locale';
|
||||
|
||||
// Shared with the bulk change, which skips these notes instead of failing. Only needs the validationFields.
|
||||
export const noteLockChangeBlockedReason = (note: NoteEntity) => {
|
||||
if (note.deleted_time) return 'Cannot change the note lock of a deleted note';
|
||||
if (note.is_conflict) return 'Cannot change the note lock of a conflict note';
|
||||
if (itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, note as ItemSlice, Setting.value('sync.userId'), BaseItem.syncShareCache)) return 'Cannot change the note lock of a read-only note';
|
||||
return '';
|
||||
};
|
||||
|
||||
// The UI hides the enable/disable actions for these cases, but the commands can also be
|
||||
// invoked directly (keyboard, plugins), so the transitions fail closed here too.
|
||||
const checkCanChangeLockState = (note: NoteEntity, noteId: string) => {
|
||||
if (!isNoteLockEnabled()) throw new Error('Note lock is not enabled');
|
||||
if (!note) throw new Error(`No such note: ${noteId}`);
|
||||
if (note.deleted_time) throw new Error('Cannot change the note lock of a deleted note');
|
||||
if (note.is_conflict) throw new Error('Cannot change the note lock of a conflict note');
|
||||
if (itemIsReadOnlySync(ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, note as ItemSlice, Setting.value('sync.userId'), BaseItem.syncShareCache)) throw new Error('Cannot change the note lock of a read-only note');
|
||||
const blockedReason = noteLockChangeBlockedReason(note);
|
||||
if (blockedReason) throw new Error(blockedReason);
|
||||
if (!NoteLockSession.instance().isUnlocked()) throw new Error('Cannot change the note lock while the session is locked');
|
||||
// The migration re-encrypts the locked notes it finds, so a note locked or unlocked meanwhile could be missed or fail it.
|
||||
if (noteLockKeyConflict()) throw new JoplinError(_('Notes cannot be locked or unlocked while a note lock key migration is required.'), ErrorCode.NoteLockKeyConflict);
|
||||
};
|
||||
|
||||
const validationFields = ['id', 'is_locked', 'deleted_time', 'is_conflict', 'share_id'];
|
||||
export const validationFields = ['id', 'is_locked', 'deleted_time', 'is_conflict', 'share_id'];
|
||||
|
||||
// These only validate and emit: the note screen listens for the event and persists the
|
||||
// change with a scheduled gated save.
|
||||
|
||||
@@ -397,6 +397,33 @@ describe('OcrService', () => {
|
||||
expect(pdfBytes.length).toBeGreaterThan(jpegBuffer.length * 1.5);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ words: ['ı', 'Мир', '日本語', '𠀀😀'], rtl: false },
|
||||
{ words: ['אור', 'חדש'], rtl: true },
|
||||
{ words: ['نور', 'باب', 'عام2024.05'], rtl: true },
|
||||
])('should create an accessible PDF with text that can be extracted: $words', async ({ words, rtl }) => {
|
||||
const jpegBuffer = await fs.readFile(`${supportDir}/photo.jpg`);
|
||||
|
||||
// Like Tesseract, words are in logical order, so right-to-left words are laid out from the right
|
||||
const ocrDetails: PdfOcrDetails = {
|
||||
version: 1,
|
||||
pages: [{
|
||||
lines: [{ words: words.map((t, i) => {
|
||||
const x = rtl ? 700 - (i + 1) * 150 : 20 + i * 150;
|
||||
return { t, bb: [x, x + 130, 20, 60] };
|
||||
}) }],
|
||||
}],
|
||||
};
|
||||
|
||||
const pdfBytes = await createAccessiblePdf([{ buffer: jpegBuffer, width: 800, height: 400 }], JSON.stringify(ocrDetails));
|
||||
const pdfPath = join(Setting.value('tempDir'), 'accessible.pdf');
|
||||
await fs.writeFile(pdfPath, pdfBytes);
|
||||
|
||||
const textByPage = await shim.pdfExtractEmbeddedText(pdfPath);
|
||||
expect(textByPage.length).toBe(1);
|
||||
expect(textByPage[0].split(/\s+/)).toEqual(words);
|
||||
});
|
||||
|
||||
it('should truncate long OCR text', async () => {
|
||||
const service = newOcrService();
|
||||
service.testing_setOcrMaxSize(10);
|
||||
|
||||
@@ -1,60 +1,236 @@
|
||||
import { PDFDocument, PDFFont, PDFPage, rgb, StandardFonts } from 'pdf-lib';
|
||||
import { beginText, endText, PDFDocument, PDFHexString, PDFOperator, PDFPage, PDFRef, PDFString, setCharacterSqueeze, setFontAndSize, setTextMatrix, setTextRenderingMode, showText, TextRenderingMode } from 'pdf-lib';
|
||||
import { PdfOcrDetails, RecognizeResultLine } from './types';
|
||||
import glyphLessFontBase64 from './glyphLessFont';
|
||||
|
||||
// The PDF OCR images are created at 2x scale by pdfToImages()
|
||||
const OCR_SCALE_FACTOR = 2;
|
||||
|
||||
// Metrics of the glyphless font, in 1/1000 of the font size
|
||||
const GLYPH_WIDTH = 500;
|
||||
const FONT_ASCENT = 800;
|
||||
const FONT_DESCENT = -200;
|
||||
|
||||
export interface PageImageWithDimensions {
|
||||
buffer: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Maps code point to CID, for characters outside the BMP
|
||||
type SupplementaryCids = Map<number, number>;
|
||||
|
||||
const surrogateStart = 0xD800;
|
||||
const surrogateEnd = 0xDFFF;
|
||||
|
||||
const toUtf16Hex = (text: string) => {
|
||||
let output = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
output += text.charCodeAt(i).toString(16).padStart(4, '0');
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
// BMP characters use their code point as CID. Supplementary characters don't fit in a two-byte CID,
|
||||
// so they are assigned CIDs in the surrogate range, which no valid character uses.
|
||||
const textToCids = (text: string, supplementaryCids: SupplementaryCids) => {
|
||||
return Array.from(text).map(char => {
|
||||
const codePoint = char.codePointAt(0);
|
||||
if (codePoint >= surrogateStart && codePoint <= surrogateEnd) return 0xFFFD;
|
||||
if (codePoint <= 0xFFFF) return codePoint;
|
||||
|
||||
let cid = supplementaryCids.get(codePoint);
|
||||
if (cid === undefined) {
|
||||
if (surrogateStart + supplementaryCids.size > surrogateEnd) return 0xFFFD;
|
||||
cid = surrogateStart + supplementaryCids.size;
|
||||
supplementaryCids.set(codePoint, cid);
|
||||
}
|
||||
return cid;
|
||||
});
|
||||
};
|
||||
|
||||
// Maps each BMP CID to itself, and supplementary CIDs to their UTF-16 sequence. A bfrange may only
|
||||
// vary in its last byte and a block may contain at most 100 entries.
|
||||
const makeToUnicodeCMap = (supplementaryCids: SupplementaryCids) => {
|
||||
const ranges: string[] = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
if (i >= surrogateStart >> 8 && i <= surrogateEnd >> 8) continue;
|
||||
const high = i.toString(16).toUpperCase().padStart(2, '0');
|
||||
ranges.push(`<${high}00> <${high}FF> <${high}00>`);
|
||||
}
|
||||
|
||||
const chars: string[] = [];
|
||||
for (const [codePoint, cid] of supplementaryCids) {
|
||||
chars.push(`<${cid.toString(16).toUpperCase()}> <${toUtf16Hex(String.fromCodePoint(codePoint))}>`);
|
||||
}
|
||||
|
||||
// cSpell:disable
|
||||
const blocks: string[] = [];
|
||||
for (let i = 0; i < ranges.length; i += 100) {
|
||||
const chunk = ranges.slice(i, i + 100);
|
||||
blocks.push(`${chunk.length} beginbfrange\n${chunk.join('\n')}\nendbfrange`);
|
||||
}
|
||||
for (let i = 0; i < chars.length; i += 100) {
|
||||
const chunk = chars.slice(i, i + 100);
|
||||
blocks.push(`${chunk.length} beginbfchar\n${chunk.join('\n')}\nendbfchar`);
|
||||
}
|
||||
|
||||
return [
|
||||
'/CIDInit /ProcSet findresource begin',
|
||||
'12 dict begin',
|
||||
'begincmap',
|
||||
'/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def',
|
||||
'/CMapName /Adobe-Identity-UCS def',
|
||||
'/CMapType 2 def',
|
||||
'1 begincodespacerange',
|
||||
'<0000> <FFFF>',
|
||||
'endcodespacerange',
|
||||
...blocks,
|
||||
'endcmap',
|
||||
'CMapName currentdict /CMap defineresource pop',
|
||||
'end',
|
||||
'end',
|
||||
].join('\n');
|
||||
// cSpell:enable
|
||||
};
|
||||
|
||||
// Standard fonts only support WinAnsi, so any other character (Cyrillic, CJK, etc.) would make
|
||||
// pdf-lib throw. Instead, like Tesseract, we use a Type0 font whose CIDs are code points, all mapped
|
||||
// to the same glyph, with a ToUnicode CMap so that the text can be extracted. The CMap depends on
|
||||
// the supplementary characters in the document, so it's assigned once all the text has been added.
|
||||
const embedGlyphLessFont = (pdfDoc: PDFDocument) => {
|
||||
const context = pdfDoc.context;
|
||||
|
||||
const fontFile = Buffer.from(glyphLessFontBase64, 'base64');
|
||||
const fontFileRef = context.register(context.flateStream(fontFile, { Length1: fontFile.length }));
|
||||
|
||||
// Two bytes per CID, all pointing to glyph 1
|
||||
const cidToGidMap = new Uint8Array(0x10000 * 2);
|
||||
for (let i = 1; i < cidToGidMap.length; i += 2) cidToGidMap[i] = 1;
|
||||
const cidToGidMapRef = context.register(context.flateStream(cidToGidMap));
|
||||
|
||||
const fontDescriptorRef = context.register(context.obj({
|
||||
Type: 'FontDescriptor',
|
||||
FontName: 'GlyphLessFont',
|
||||
Flags: 5,
|
||||
FontBBox: [0, FONT_DESCENT, GLYPH_WIDTH, FONT_ASCENT],
|
||||
ItalicAngle: 0,
|
||||
Ascent: FONT_ASCENT,
|
||||
Descent: FONT_DESCENT,
|
||||
CapHeight: FONT_ASCENT,
|
||||
StemV: 80,
|
||||
FontFile2: fontFileRef,
|
||||
}));
|
||||
|
||||
const cidFontRef = context.register(context.obj({
|
||||
Type: 'Font',
|
||||
Subtype: 'CIDFontType2',
|
||||
BaseFont: 'GlyphLessFont',
|
||||
CIDSystemInfo: {
|
||||
Registry: PDFString.of('Adobe'),
|
||||
Ordering: PDFString.of('Identity'),
|
||||
Supplement: 0,
|
||||
},
|
||||
FontDescriptor: fontDescriptorRef,
|
||||
CIDToGIDMap: cidToGidMapRef,
|
||||
DW: GLYPH_WIDTH,
|
||||
}));
|
||||
|
||||
const toUnicodeRef = context.nextRef();
|
||||
|
||||
const fontRef = context.register(context.obj({
|
||||
Type: 'Font',
|
||||
Subtype: 'Type0',
|
||||
BaseFont: 'GlyphLessFont',
|
||||
Encoding: 'Identity-H',
|
||||
DescendantFonts: [cidFontRef],
|
||||
ToUnicode: toUnicodeRef,
|
||||
}));
|
||||
|
||||
return { fontRef, toUnicodeRef };
|
||||
};
|
||||
|
||||
const rtlScriptRegex = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}]/u;
|
||||
const isRtlLetter = (char: string) => rtlScriptRegex.test(char) && !/\p{N}/u.test(char);
|
||||
const isLtrStrong = (char: string) => /[\p{L}\p{N}]/u.test(char) && !isRtlLetter(char);
|
||||
|
||||
// OCR text is in logical order, but viewers expect PDF text in visual (left-to-right) order and
|
||||
// reorder it when extracting. So in right-to-left words, the runs are reversed, as well as the
|
||||
// characters within right-to-left runs (numbers and Latin text remain left-to-right).
|
||||
const toVisualOrder = (text: string) => {
|
||||
const chars = Array.from(text);
|
||||
if (!chars.some(isRtlLetter)) return text;
|
||||
|
||||
// As in the Unicode bidi algorithm, combining marks take the direction of their base, and a
|
||||
// separator between two left-to-right characters (e.g. in "2024.05") is left-to-right too.
|
||||
const isLtr = chars.map(isLtrStrong);
|
||||
for (let i = 1; i < chars.length; i++) {
|
||||
if (/\p{M}/u.test(chars[i])) isLtr[i] = isLtr[i - 1];
|
||||
}
|
||||
for (let i = 1; i < chars.length - 1; i++) {
|
||||
if (!isRtlLetter(chars[i]) && isLtr[i - 1] && isLtr[i + 1]) isLtr[i] = true;
|
||||
}
|
||||
|
||||
const runs: { ltr: boolean; chars: string[] }[] = [];
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const lastRun = runs[runs.length - 1];
|
||||
if (lastRun && lastRun.ltr === isLtr[i]) {
|
||||
lastRun.chars.push(chars[i]);
|
||||
} else {
|
||||
runs.push({ ltr: isLtr[i], chars: [chars[i]] });
|
||||
}
|
||||
}
|
||||
|
||||
return runs.reverse().map(run => run.ltr ? run.chars.join('') : run.chars.reverse().join('')).join('');
|
||||
};
|
||||
|
||||
// Adds an invisible text layer to a PDF page based on OCR word positions.
|
||||
// The text is rendered in "invisible" mode (render mode 3) so it doesn't
|
||||
// appear visually but can be selected, copied, and read by screen readers.
|
||||
const addInvisibleTextLayer = (
|
||||
page: PDFPage,
|
||||
lines: RecognizeResultLine[],
|
||||
font: PDFFont,
|
||||
_pageWidth: number,
|
||||
fontRef: PDFRef,
|
||||
pageHeight: number,
|
||||
): void => {
|
||||
supplementaryCids: SupplementaryCids,
|
||||
) => {
|
||||
const fontName = page.node.newFontDictionary('GlyphLessFont', fontRef);
|
||||
const operators: PDFOperator[] = [beginText(), setTextRenderingMode(TextRenderingMode.Invisible)];
|
||||
|
||||
for (const line of lines) {
|
||||
for (const word of line.words) {
|
||||
const text = word.t;
|
||||
if (!text || !text.trim()) continue;
|
||||
|
||||
// Bounding box format from Tesseract: [x0, x1, y0, y1]
|
||||
const [x0, , y0, y1] = word.bb;
|
||||
const [x0, x1, y0, y1] = word.bb;
|
||||
|
||||
// Convert from OCR coordinates (2x scale, origin top-left)
|
||||
// to PDF coordinates (1x scale, origin bottom-left)
|
||||
const pdfX = x0 / OCR_SCALE_FACTOR;
|
||||
const pdfY = pageHeight - (y1 / OCR_SCALE_FACTOR); // Flip Y axis and use bottom of bbox
|
||||
|
||||
// Calculate word height in PDF coordinates (width not currently used)
|
||||
const wordWidth = (x1 - x0) / OCR_SCALE_FACTOR;
|
||||
const wordHeight = (y1 - y0) / OCR_SCALE_FACTOR;
|
||||
|
||||
// Estimate font size based on word height
|
||||
// We want the text to fit within the bounding box
|
||||
const fontSize = Math.max(1, wordHeight * 0.85);
|
||||
// The font's ascent-to-descent height is one em, so this makes the glyphs span the word's
|
||||
// bounding box, with the baseline placed above its bottom by the descent.
|
||||
const fontSize = Math.max(1, wordHeight);
|
||||
const pdfX = x0 / OCR_SCALE_FACTOR;
|
||||
const pdfY = pageHeight - (y1 / OCR_SCALE_FACTOR) - fontSize * FONT_DESCENT / 1000;
|
||||
|
||||
// Draw the text as invisible (using transparent color)
|
||||
// PDF.js and screen readers can still detect and read this text
|
||||
page.drawText(text, {
|
||||
x: pdfX,
|
||||
y: pdfY,
|
||||
size: fontSize,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
opacity: 0, // Make text invisible
|
||||
// Note: pdf-lib doesn't directly support render mode 3 (invisible),
|
||||
// but opacity: 0 achieves the same visual effect while keeping
|
||||
// the text selectable
|
||||
});
|
||||
// Stretch the text horizontally so that its selection matches the word on the image
|
||||
const cids = textToCids(toVisualOrder(text), supplementaryCids);
|
||||
const naturalWidth = cids.length * fontSize * GLYPH_WIDTH / 1000;
|
||||
const horizontalScale = wordWidth > 0 ? wordWidth / naturalWidth * 100 : 100;
|
||||
|
||||
operators.push(
|
||||
setFontAndSize(fontName, fontSize),
|
||||
setCharacterSqueeze(horizontalScale),
|
||||
setTextMatrix(1, 0, 0, 1, pdfX, pdfY),
|
||||
showText(PDFHexString.of(cids.map(cid => cid.toString(16).padStart(4, '0')).join(''))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
operators.push(endText());
|
||||
page.pushOperators(...operators);
|
||||
};
|
||||
|
||||
// Creates an accessible PDF by overlaying invisible text on top of page images.
|
||||
@@ -78,8 +254,8 @@ const createAccessiblePdf = async (
|
||||
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
|
||||
// Embed a standard font for the invisible text layer
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const { fontRef, toUnicodeRef } = embedGlyphLessFont(pdfDoc);
|
||||
const supplementaryCids: SupplementaryCids = new Map();
|
||||
|
||||
for (let pageIndex = 0; pageIndex < pageImages.length; pageIndex++) {
|
||||
const pageImage = pageImages[pageIndex];
|
||||
@@ -104,9 +280,11 @@ const createAccessiblePdf = async (
|
||||
});
|
||||
|
||||
// Add invisible text layer on top
|
||||
addInvisibleTextLayer(page, pageOcr.lines, font, pageWidth, pageHeight);
|
||||
addInvisibleTextLayer(page, pageOcr.lines, fontRef, pageHeight, supplementaryCids);
|
||||
}
|
||||
|
||||
pdfDoc.context.assign(toUnicodeRef, pdfDoc.context.flateStream(makeToUnicodeCMap(supplementaryCids)));
|
||||
|
||||
return pdfDoc.save();
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Minimal TrueType font generated with fontTools' FontBuilder. Same idea as Tesseract's GlyphLessFont,
|
||||
// used for invisible OCR text. Glyph 1 is a 500x1000 rectangle, with ascent 800 and descent -200.
|
||||
// It's never drawn (render mode 3), but macOS PDFKit (Preview) needs a non-zero descent and a glyph
|
||||
// outline to compute text bounds, otherwise the text can't be selected.
|
||||
const glyphLessFontBase64 = 'AAEAAAAKAIAAAwAgT1MvMkUAQ7AAAAEoAAAAYGNtYXAADABzAAABkAAAADRnbHlmA24B+AAAAcwAAAAaaGVhZC9AAeoAAACsAAAANmhoZWEFFgEuAAAA5AAAACRobXR4AfQAAAAAAYgAAAAGbG9jYQANAAAAAAHEAAAABm1heHAABAAGAAABCAAAACBuYW1lGZ8ZNAAAAegAAABycG9zdJ5/ds8AAAJcAAAALQABAAAAAQAAN7DqNF8PPPUAAwPoAAAAAOcaX1kAAAAA5xpfWQAA/zgB9AMgAAAAAwACAAAAAAAAAAEAAAMg/zgAAAH0AAAAAAH0AAEAAAAAAAAAAAAAAAAAAAABAAEAAAACAAQAAQAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAwH0AZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAPz8/PwAAACAAIAMg/zgAAAMgAMgAAAAAAAAAAAAAAAAAAAAgAAAB9AAAAAAAAAAAAAIAAAADAAAAFAADAAEAAAAUAAQAIAAAAAQABAABAAAAIP//AAAAIP///+EAAQAAAAAAAAAAAA0AAAABAAD/OAH0AyAAAwAAFREhEQH0yAPo/BgAAAAAAAAEADYAAQAAAAAAAQANAAAAAQAAAAAAAgAHAA0AAwABBAkAAQAaABQAAwABBAkAAgAOAC5HbHlwaExlc3NGb250UmVndWxhcgBHAGwAeQBwAGgATABlAHMAcwBGAG8AbgB0AFIAZQBnAHUAbABhAHIAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAECBmdseXBoMQAAAA==';
|
||||
|
||||
export default glyphLessFontBase64;
|
||||
@@ -10,6 +10,7 @@ import Logger from '@joplin/utils/Logger';
|
||||
import RepositoryApi from './RepositoryApi';
|
||||
import { produce } from 'immer';
|
||||
import { PluginManifest } from './utils/types';
|
||||
import { BlockedPluginVersions, isRepoManifestBlocked, isVersionBlocked } from './utils/blockedVersions';
|
||||
import isCompatible from './utils/isCompatible';
|
||||
import { AppType } from './api/types';
|
||||
import minVersionForPlatform from './utils/isCompatible/minVersionForPlatform';
|
||||
@@ -511,7 +512,9 @@ export default class PluginService extends BaseService {
|
||||
// Load only the manifest first to check if the plugin is
|
||||
// enabled before doing the expensive full load.
|
||||
let plugin = await this.loadPluginFromPath(pluginPath, true);
|
||||
const enabled = this.pluginEnabled(settings, plugin.id);
|
||||
const blocked = this.isBlocked(plugin.manifest);
|
||||
if (blocked) logger.warn(`Plugin version has been blocked: ${plugin.id}@${plugin.manifest.version}`);
|
||||
const enabled = this.pluginEnabled(settings, plugin.id) && !blocked;
|
||||
if (enabled) {
|
||||
logger.info(`Loading full plugin: ${plugin.id}`);
|
||||
plugin = await this.loadPluginFromPath(pluginPath, false);
|
||||
@@ -583,8 +586,13 @@ export default class PluginService extends BaseService {
|
||||
return shim.mobilePlatform() ? AppType.Mobile : AppType.Desktop;
|
||||
}
|
||||
|
||||
public isBlocked(manifest: PluginManifest) {
|
||||
const blockedVersions: BlockedPluginVersions = Setting.value('plugins.blockedVersions');
|
||||
return isRepoManifestBlocked(manifest) || isVersionBlocked(blockedVersions?.[manifest.id], manifest.version);
|
||||
}
|
||||
|
||||
public isCompatible(manifest: PluginManifest): boolean {
|
||||
return isCompatible(this.appVersion_, this.appType_, manifest);
|
||||
return isCompatible(this.appVersion_, this.appType_, manifest) && !this.isBlocked(manifest);
|
||||
}
|
||||
|
||||
private validateManifest(manifest: unknown): void {
|
||||
@@ -600,6 +608,8 @@ export default class PluginService extends BaseService {
|
||||
return _('Invalid plugin manifest: %s', message);
|
||||
}
|
||||
|
||||
if (this.isBlocked(manifest)) return _('This version of the plugin has been disabled by the Joplin team for security reasons.');
|
||||
|
||||
if (this.isCompatible(manifest)) return null;
|
||||
|
||||
const minVersion = minVersionForPlatform(this.appType_, manifest);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PluginManifest } from './utils/types';
|
||||
const md5 = require('md5');
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import isCompatible from './utils/isCompatible';
|
||||
import { isRepoManifestBlocked } from './utils/blockedVersions';
|
||||
import { AppType } from '../../models/Setting';
|
||||
|
||||
const logger = Logger.create('RepositoryApi');
|
||||
@@ -92,6 +93,14 @@ export default class RepositoryApi {
|
||||
}
|
||||
|
||||
public async initialize() {
|
||||
await this.initializeManifestsOnly();
|
||||
await this.loadRelease();
|
||||
|
||||
this.lastInitializedTime_ = Date.now();
|
||||
}
|
||||
|
||||
// Skips the GitHub release API call, which is rate-limited and only needed to download plugins
|
||||
public async initializeManifestsOnly() {
|
||||
// https://github.com/joplin/plugins
|
||||
// https://api.github.com/repos/joplin/plugins/releases
|
||||
this.githubApiUrl_ = this.baseUrl_.replace(/^(https:\/\/)(github\.com\/)(.*)$/, '$1api.$2repos/$3');
|
||||
@@ -102,9 +111,6 @@ export default class RepositoryApi {
|
||||
this.isUsingDefaultContentUrl_ = this.contentBaseUrl_ === defaultContentBaseUrl;
|
||||
|
||||
await this.loadManifests();
|
||||
await this.loadRelease();
|
||||
|
||||
this.lastInitializedTime_ = Date.now();
|
||||
}
|
||||
|
||||
public async reinitialize() {
|
||||
@@ -245,6 +251,7 @@ export default class RepositoryApi {
|
||||
const manifest = manifests.find(m => m.id === pluginId);
|
||||
if (!manifest) throw new Error(`No manifest for plugin ID "${pluginId}"`);
|
||||
if (this.isBlockedByInstallMode(manifest)) throw new Error(`Plugin is blocked by intstallation policy. ID "${pluginId}"`);
|
||||
if (isRepoManifestBlocked(manifest)) throw new Error(`This version of the plugin has been blocked: ${pluginId}@${manifest.version}`);
|
||||
|
||||
const fileUrl = this.assetFileUrl(manifest.id); // this.repoFileUrl(`plugins/${manifest.id}/plugin.jpl`);
|
||||
const hash = md5(Date.now() + Math.random());
|
||||
@@ -284,7 +291,7 @@ export default class RepositoryApi {
|
||||
if (!manifest) return false;
|
||||
|
||||
const supportsCurrentAppVersion = compareVersions(installedVersion, manifest.version) < 0 && isCompatible(this.appVersion_, this.appType_, manifest);
|
||||
return supportsCurrentAppVersion && !this.isBlockedByInstallMode(manifest);
|
||||
return supportsCurrentAppVersion && !this.isBlockedByInstallMode(manifest) && !isRepoManifestBlocked(manifest);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -254,4 +254,34 @@ describe('loadPlugins', () => {
|
||||
// The bad plugin should not be running
|
||||
expect(failingPluginRunner.runningPluginIds).not.toContain(badPluginId);
|
||||
});
|
||||
|
||||
test('should stop and not run blocked plugin versions', async () => {
|
||||
const blockedId = 'joplin.test.plugin.blocked';
|
||||
const otherId = 'joplin.test.plugin.other';
|
||||
await createTestPlugin({ ...defaultManifestProperties, id: blockedId, name: 'Blocked' });
|
||||
await createTestPlugin({ ...defaultManifestProperties, id: otherId, name: 'Other' });
|
||||
|
||||
const pluginRunner = new MockPluginRunner();
|
||||
const loadPluginsOptions: LoadPluginsProps = {
|
||||
pluginRunner,
|
||||
pluginSettings: Setting.value('plugins.states'),
|
||||
platformImplementation,
|
||||
store: createMockReduxStore(),
|
||||
reloadAll: false,
|
||||
cancelEvent: { cancelled: false },
|
||||
};
|
||||
|
||||
await loadPlugins(loadPluginsOptions);
|
||||
await pluginRunner.waitForAllToBeRunning([blockedId, otherId]);
|
||||
|
||||
Setting.setValue('plugins.blockedVersions', { [blockedId]: [defaultManifestProperties.version] });
|
||||
await withWarningSilenced(/Plugin version has been blocked/, async () => {
|
||||
await loadPlugins(loadPluginsOptions);
|
||||
});
|
||||
|
||||
expect(pluginRunner.runningPluginIds).toEqual([otherId]);
|
||||
const blockedManifest = PluginService.instance().pluginById(blockedId).manifest;
|
||||
expect(PluginService.instance().isCompatible(blockedManifest)).toBe(false);
|
||||
expect(PluginService.instance().describeIncompatibility(blockedManifest)).toContain('security reasons');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import Setting from '../../models/Setting';
|
||||
import shim from '../../shim';
|
||||
import PluginService from './PluginService';
|
||||
import RepositoryApi from './RepositoryApi';
|
||||
import { blockedVersionsFromManifests, mergeBlockedVersions } from './utils/blockedVersions';
|
||||
|
||||
const hasUserInstalledPlugins = async () => {
|
||||
const pluginDir = Setting.value('pluginDir');
|
||||
if (!(await shim.fsDriver().exists(pluginDir))) return false;
|
||||
return (await shim.fsDriver().readDirStats(pluginDir)).length > 0;
|
||||
};
|
||||
|
||||
// Returns the IDs of the running plugins that are now blocked
|
||||
const refreshBlockedPluginVersions = async (repoApi: RepositoryApi) => {
|
||||
if (!(await hasUserInstalledPlugins())) return [];
|
||||
|
||||
await repoApi.initializeManifestsOnly();
|
||||
const current = Setting.value('plugins.blockedVersions') ?? {};
|
||||
let fetched = blockedVersionsFromManifests(await repoApi.manifests());
|
||||
if (!repoApi.isUsingDefaultContentUrl) fetched = mergeBlockedVersions(current, fetched);
|
||||
// Avoids a needless settings change, which on mobile reloads all plugins
|
||||
if (JSON.stringify(fetched) !== JSON.stringify(current)) Setting.setValue('plugins.blockedVersions', fetched);
|
||||
|
||||
const service = PluginService.instance();
|
||||
return service.pluginIds.filter(id => {
|
||||
const plugin = service.pluginById(id);
|
||||
return plugin.running && service.isBlocked(plugin.manifest);
|
||||
});
|
||||
};
|
||||
|
||||
export default refreshBlockedPluginVersions;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { isValidBlockedVersionEntry, isVersionBlocked, mergeBlockedVersions } from './blockedVersions';
|
||||
|
||||
describe('blockedVersions', () => {
|
||||
|
||||
test.each([
|
||||
[['1.2.3'], '1.2.3', true],
|
||||
[['1.2.3'], '1.2.4', false],
|
||||
[['>=1.2.3'], '1.2.2', false],
|
||||
[['>=1.2.3'], '1.2.4', true],
|
||||
[['>=1.2.3'], '2.0.0', true],
|
||||
[['1.2.3', '1.2.4'], '1.2.5', false],
|
||||
[['*'], '0.0.1', true],
|
||||
[['not a version'], '1.2.3', true],
|
||||
[[], '1.2.3', false],
|
||||
[undefined, '1.2.3', false],
|
||||
])('should tell if a version is blocked (%j, %s)', (blockedVersions, version, expected) => {
|
||||
expect(isVersionBlocked(blockedVersions, version)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['*', true],
|
||||
['1.2.3', true],
|
||||
['>=1.2.3', true],
|
||||
['>=1.2.3 <1.2.5', true],
|
||||
['1.2.3 - 1.2.4', true],
|
||||
['1.2.3 || >=2.0.0', true],
|
||||
['<1.0.0 || garbage', false],
|
||||
['>=1.2.3 garbage', false],
|
||||
['>=1.2.3\t<2.0.0', false],
|
||||
['1.2', false],
|
||||
['v1.2.3', false],
|
||||
['>=x', false],
|
||||
['', false],
|
||||
[123, false],
|
||||
])('should validate blocked version entries (%j)', (entry, expected) => {
|
||||
expect(isValidBlockedVersionEntry(entry)).toBe(expected);
|
||||
});
|
||||
|
||||
test('should only add blocks when merging', () => {
|
||||
const current = { 'plugin.a': ['>=1.2.3'], 'plugin.b': ['*'] };
|
||||
const fetched = { 'plugin.a': ['1.2.3', '1.2.4'], 'plugin.c': ['2.0.0'] };
|
||||
expect(mergeBlockedVersions(current, fetched)).toEqual({
|
||||
'plugin.a': ['>=1.2.3', '1.2.3', '1.2.4'],
|
||||
'plugin.b': ['*'],
|
||||
'plugin.c': ['2.0.0'],
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { satisfies, validateStrict } from 'compare-versions';
|
||||
import { PluginManifest } from './types';
|
||||
|
||||
// Maps a plugin ID to its blocked versions. Each entry is "*", an exact version, or a semver range
|
||||
// such as ">=1.2.3", so that versions published by an attacker after the block are also covered.
|
||||
export type BlockedPluginVersions = Record<string, string[]>;
|
||||
|
||||
const entryMatches = (entry: string, version: string) => {
|
||||
if (entry === '*') return true;
|
||||
try {
|
||||
return satisfies(version, entry);
|
||||
} catch (error) {
|
||||
// Fail closed: a malformed entry still blocks the plugin
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const isVersionBlocked = (blockedVersions: string[] | undefined, version: string) => {
|
||||
if (!Array.isArray(blockedVersions)) return false;
|
||||
return blockedVersions.some(entry => entryMatches(entry, version));
|
||||
};
|
||||
|
||||
// Each comparator is checked on its own because satisfies() short-circuits, so "<1.0.0 || garbage"
|
||||
// would pass. This also rejects partial versions such as "1.2", which silently match nothing.
|
||||
// Only spaces are accepted as separators since satisfies() throws on tabs.
|
||||
export const isValidBlockedVersionEntry = (entry: unknown) => {
|
||||
if (typeof entry !== 'string') return false;
|
||||
if (entry === '*') return true;
|
||||
const comparators = entry.split('||').flatMap(part => part.trim().split(/ +- +| +/));
|
||||
return comparators.every(c => validateStrict(c.replace(/^(<=|>=|<|>|=|\^|~)/, '')));
|
||||
};
|
||||
|
||||
export const isRepoManifestBlocked = (manifest: PluginManifest) => {
|
||||
return isVersionBlocked(manifest._blocked_versions, manifest.version);
|
||||
};
|
||||
|
||||
// A mirror may serve a stale copy of the manifests, so it can add blocks but never lift them
|
||||
export const mergeBlockedVersions = (current: BlockedPluginVersions, fetched: BlockedPluginVersions) => {
|
||||
const output = { ...current };
|
||||
for (const [pluginId, versions] of Object.entries(fetched)) {
|
||||
output[pluginId] = [...new Set([...(output[pluginId] ?? []), ...versions])];
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
export const blockedVersionsFromManifests = (manifests: PluginManifest[]) => {
|
||||
const output: BlockedPluginVersions = {};
|
||||
for (const manifest of manifests) {
|
||||
if (Array.isArray(manifest._blocked_versions) && manifest._blocked_versions.length) {
|
||||
output[manifest.id] = manifest._blocked_versions.filter(v => typeof v === 'string');
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
@@ -40,4 +40,5 @@ export interface PluginManifest {
|
||||
_npm_package_name?: string;
|
||||
_obsolete?: boolean;
|
||||
_recommended?: boolean;
|
||||
_blocked_versions?: string[];
|
||||
}
|
||||
@@ -272,6 +272,33 @@ describe('Synchronizer.resources', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should not create a conflict for metadata changes to a resource synced without blob_updated_time', async () => {
|
||||
const note = await Note.save({ title: 'ma note' });
|
||||
await shim.attachFileToNote(note, `${supportDir}/sample.txt`);
|
||||
const resource = (await Resource.all())[0];
|
||||
await synchronizerStart();
|
||||
|
||||
// Simulate a resource uploaded by a client that predates blob_updated_time
|
||||
const metadataPath = `${resource.id}.md`;
|
||||
const metadata = await fileApi().get(metadataPath);
|
||||
const newMetadata = metadata.replace(/^blob_updated_time: .*\n/m, '');
|
||||
expect(newMetadata).not.toContain('blob_updated_time');
|
||||
await fileApi().put(metadataPath, newMetadata);
|
||||
|
||||
await msleep(10);
|
||||
await switchClient(2);
|
||||
await synchronizerStart();
|
||||
await resourceFetcher().startAndWait();
|
||||
expect((await Resource.load(resource.id)).blob_updated_time).toBe(resource.updated_time);
|
||||
await Resource.save({ id: resource.id, title: 'title from client 2' });
|
||||
await synchronizerStart();
|
||||
|
||||
await switchClient(1);
|
||||
await Resource.save({ id: resource.id, title: 'title from client 1' });
|
||||
await synchronizerStart();
|
||||
expect((await Note.all()).length).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle resource conflicts if a resource is changed locally but deleted remotely', async () => {
|
||||
{
|
||||
const tempFile = tempFilePath('txt');
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
"access": "public"
|
||||
},
|
||||
"description": "Used to import a OneNote archive into Joplin",
|
||||
"version": "3.7.3",
|
||||
"version": "3.7.4",
|
||||
"license": "MPL-2.0-no-copyleft-exception",
|
||||
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/onenote-converter",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/onenote-converter"
|
||||
},
|
||||
"main": "./renderer/pkg/renderer.js",
|
||||
"scripts": {
|
||||
"build": "node ./tools/build.js --profile=release",
|
||||
@@ -16,6 +20,5 @@
|
||||
"devDependencies": {
|
||||
"wasm-pack": "0.13.1",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { applyManifestOverrides, getObsoleteManifests, getSupersededPackages, ManifestOverrides } from './overrideUtils';
|
||||
import { applyManifestOverrides, getObsoleteManifests, getSupersededPackages, ManifestOverrides, validateManifestOverrides } from './overrideUtils';
|
||||
|
||||
describe('overrideUtils', () => {
|
||||
|
||||
@@ -104,4 +104,23 @@ describe('overrideUtils', () => {
|
||||
const supersededPackages = getSupersededPackages(manifestOverrides);
|
||||
expect(supersededPackages).toMatchObject(['joplin-plugin-benji-favorites']);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[['>=1.2.3', '1.0.0'], true],
|
||||
[['1.2'], false],
|
||||
[[] as string[], false],
|
||||
['1.2.3', false],
|
||||
])('should validate blocked versions (%j)', (blockedVersions, isValid) => {
|
||||
const overrides = { 'org.joplinapp.plugins.ToggleSidebars': { _blocked_versions: blockedVersions } } as ManifestOverrides;
|
||||
if (isValid) {
|
||||
expect(() => validateManifestOverrides(overrides)).not.toThrow();
|
||||
} else {
|
||||
expect(() => validateManifestOverrides(overrides)).toThrow('Invalid _blocked_versions');
|
||||
}
|
||||
});
|
||||
|
||||
test('should not allow blocking a plugin that is not in the manifests', () => {
|
||||
const overrides: ManifestOverrides = { 'does.not.exist': { _blocked_versions: ['*'] } };
|
||||
expect(() => applyManifestOverrides({}, overrides)).toThrow('Cannot block plugin does.not.exist');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as path from 'path';
|
||||
import { readJsonFile } from './utils';
|
||||
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
||||
import { isValidBlockedVersionEntry } from '@joplin/lib/services/plugins/utils/blockedVersions';
|
||||
|
||||
export interface ManifestOverride {
|
||||
_obsolete?: boolean;
|
||||
_recommended?: boolean;
|
||||
_superseded_package?: string;
|
||||
_blocked_versions?: string[];
|
||||
}
|
||||
|
||||
export type ManifestOverrides = Record<string, ManifestOverride>;
|
||||
@@ -16,6 +18,10 @@ export function applyManifestOverrides(manifests: PluginManifests, overrides: Ma
|
||||
const manifest = manifests[pluginId] as PluginManifest & Record<string, unknown>;
|
||||
|
||||
if (!manifest) {
|
||||
if (override._blocked_versions) {
|
||||
throw new Error(`Cannot block plugin ${pluginId}: it is not in manifests.json`);
|
||||
}
|
||||
|
||||
// If the manifest does not exist in the destination, it means the
|
||||
// plugin has been taken out, so the obsolete property should be set
|
||||
// to `true`. If not, it's an error.
|
||||
@@ -49,8 +55,20 @@ function pluginManifestOverridesPath(repoDir: string): string {
|
||||
return path.resolve(repoDir, 'manifestOverrides.json');
|
||||
}
|
||||
|
||||
export const validateManifestOverrides = (overrides: ManifestOverrides) => {
|
||||
for (const [pluginId, override] of Object.entries(overrides)) {
|
||||
const blockedVersions = override._blocked_versions;
|
||||
if (blockedVersions === undefined) continue;
|
||||
if (!Array.isArray(blockedVersions) || !blockedVersions.length || !blockedVersions.every(isValidBlockedVersionEntry)) {
|
||||
throw new Error(`Invalid _blocked_versions for plugin ${pluginId}: ${JSON.stringify(blockedVersions)}. It must be a non-empty array of "*", exact versions or semver ranges.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function readManifestOverrides(repoDir: string): Promise<ManifestOverrides> {
|
||||
return readJsonFile(pluginManifestOverridesPath(repoDir), {});
|
||||
const overrides: ManifestOverrides = await readJsonFile(pluginManifestOverridesPath(repoDir), {});
|
||||
validateManifestOverrides(overrides);
|
||||
return overrides;
|
||||
}
|
||||
|
||||
export const getSupersededPackages = (manifestOverrides: ManifestOverrides): string[] => {
|
||||
|
||||
@@ -105,6 +105,19 @@ describe('validateUntrustedManifest', () => {
|
||||
).toThrow('mark itself as recommended');
|
||||
});
|
||||
|
||||
test('should not allow plugin authors to set their own blocked versions', () => {
|
||||
const newManifest = {
|
||||
id: 'joplin-plugin.this.is.another.test',
|
||||
_blocked_versions: [] as string[],
|
||||
_npm_package_name: 'joplin-plugin-another-test',
|
||||
version: '1.2.3',
|
||||
};
|
||||
|
||||
expect(
|
||||
() => validateUntrustedManifest(newManifest as unknown as PluginManifest, originalManifests, { source: 'npm' }),
|
||||
).toThrow('cannot set its own blocked versions');
|
||||
});
|
||||
|
||||
test('should not allow changing the NPM package for an existing plugin', () => {
|
||||
const newManifest = {
|
||||
id: 'joplin-plugin.this.is.a.test',
|
||||
|
||||
@@ -26,6 +26,10 @@ const validateUntrustedManifest = (
|
||||
throw new Error(`Plugin ${manifest.id} cannot mark itself as recommended.`);
|
||||
}
|
||||
|
||||
if (typeof manifest._blocked_versions !== 'undefined') {
|
||||
throw new Error(`Plugin ${manifest.id} cannot set its own blocked versions.`);
|
||||
}
|
||||
|
||||
if (options.source === 'repository') {
|
||||
if (!manifest.repository_url?.trim()) {
|
||||
throw new Error(`Plugin ${manifest.id} must specify repository_url when published from a repository.`);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
{
|
||||
"name": "@joplin/plugin-repo-cli",
|
||||
"version": "3.7.6",
|
||||
"version": "3.7.7",
|
||||
"description": "",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/plugin-repo-cli"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"bin": "./dist/index.js",
|
||||
"publishConfig": {
|
||||
@@ -25,9 +30,9 @@
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/lib": "^3.7.3",
|
||||
"@joplin/tools": "^3.7.3",
|
||||
"@joplin/utils": "^3.7.3",
|
||||
"@joplin/lib": "^3.7.4",
|
||||
"@joplin/tools": "^3.7.4",
|
||||
"@joplin/utils": "^3.7.4",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "24.13.3",
|
||||
@@ -36,6 +41,5 @@
|
||||
"typescript": "5.9.3",
|
||||
"webpack": "5.97.1",
|
||||
"webpack-cli": "4.10.0"
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/react-native-saf-x",
|
||||
"version": "3.7.3",
|
||||
"version": "3.7.4",
|
||||
"description": "a module to help work with scoped storages on android easily",
|
||||
"main": "src/index",
|
||||
"react-native": "src/index",
|
||||
@@ -36,7 +36,11 @@
|
||||
"storage-access-framework",
|
||||
"fs"
|
||||
],
|
||||
"repository": "https://github.com/jd1378/react-native-saf-x",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/react-native-saf-x"
|
||||
},
|
||||
"author": "Javad Mnjd (https://github.com/jd1378)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"homepage": "https://github.com/laurent22/joplin/tree/dev/packages/react-native-saf-x",
|
||||
@@ -52,6 +56,5 @@
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"name": "@joplin/renderer",
|
||||
"version": "3.7.3",
|
||||
"version": "3.7.4",
|
||||
"description": "The Joplin note renderer, used the mobile and desktop application",
|
||||
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/renderer",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/renderer"
|
||||
},
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"publishConfig": {
|
||||
@@ -30,9 +34,9 @@
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/fork-htmlparser2": "^4.1.66",
|
||||
"@joplin/fork-uslug": "^2.0.9",
|
||||
"@joplin/utils": "^3.7.3",
|
||||
"@joplin/fork-htmlparser2": "^4.1.67",
|
||||
"@joplin/fork-uslug": "^2.0.10",
|
||||
"@joplin/utils": "^3.7.4",
|
||||
"@types/json5": "2.2.0",
|
||||
"abcjs": "6.6.4",
|
||||
"font-awesome-filetypes": "2.1.0",
|
||||
@@ -56,6 +60,5 @@
|
||||
"markdown-it-sup": "2.0.0",
|
||||
"markdown-it-toc-done-right": "4.2.0",
|
||||
"md5": "2.3.0"
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
"jquery": "3.7.1",
|
||||
"knex": "3.3.0",
|
||||
"koa": "2.16.4",
|
||||
"ldapts": "8.1.8",
|
||||
"ldapts": "8.2.0",
|
||||
"markdown-it": "13.0.2",
|
||||
"mustache": "4.2.0",
|
||||
"node-cron": "4.5.0",
|
||||
|
||||
@@ -378,3 +378,7 @@ instr
|
||||
mixmark
|
||||
Hrvatski
|
||||
Defense
|
||||
glyphless
|
||||
bfrange
|
||||
flate
|
||||
Thaana
|
||||
+1630
-2453
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/tools",
|
||||
"version": "3.7.3",
|
||||
"version": "3.7.4",
|
||||
"description": "Various tools for Joplin",
|
||||
"main": "index.js",
|
||||
"author": "Laurent Cozic",
|
||||
@@ -17,13 +17,14 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/laurent22/joplin.git"
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/tools"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@joplin/lib": "^3.7.3",
|
||||
"@joplin/renderer": "^3.7.3",
|
||||
"@joplin/utils": "^3.7.3",
|
||||
"@joplin/lib": "^3.7.4",
|
||||
"@joplin/renderer": "^3.7.4",
|
||||
"@joplin/utils": "^3.7.4",
|
||||
"compare-versions": "6.1.1",
|
||||
"dayjs": "1.11.21",
|
||||
"execa": "4.1.0",
|
||||
@@ -45,7 +46,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/plugin-sitemap": "3.10.2",
|
||||
"@joplin/fork-htmlparser2": "^4.1.66",
|
||||
"@joplin/fork-htmlparser2": "^4.1.67",
|
||||
"@rmp135/sql-ts": "1.18.1",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
@@ -66,6 +67,5 @@
|
||||
"style-to-js": "1.1.21",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { bumpVersions, PackageJson, sortByDependencies } from './releaseSubPackages';
|
||||
|
||||
const toPackages = (manifests: PackageJson[]) => manifests.map(manifest => ({ dir: '', manifest }));
|
||||
|
||||
describe('releaseSubPackages', () => {
|
||||
|
||||
test('should bump all versions and update ranges between public packages', () => {
|
||||
const result = bumpVersions([
|
||||
{ name: 'utils', version: '3.7.3' },
|
||||
{ name: 'fork', version: '4.1.66' },
|
||||
{
|
||||
name: 'lib',
|
||||
version: '3.7.3',
|
||||
dependencies: { 'utils': '^3.7.3', 'fork': '~4.1.60', 'external': '^3.7.3' },
|
||||
devDependencies: { 'utils': '~3.7', 'other': '1.0.0' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.map(m => m.version)).toEqual(['3.7.4', '4.1.67', '3.7.4']);
|
||||
expect(result[2].dependencies).toEqual({ 'utils': '^3.7.4', 'fork': '~4.1.67', 'external': '^3.7.3' });
|
||||
expect(result[2].devDependencies).toEqual({ 'utils': '~3.7', 'other': '1.0.0' });
|
||||
});
|
||||
|
||||
test('should sort packages so that dependencies come first', () => {
|
||||
const result = sortByDependencies(toPackages([
|
||||
{ name: 'tools', version: '1.0.0', dependencies: { 'lib': '^1.0.0' } },
|
||||
{ name: 'lib', version: '1.0.0', dependencies: { 'utils': '^1.0.0' } },
|
||||
{ name: 'utils', version: '1.0.0', devDependencies: { 'tools': '^1.0.0' } },
|
||||
]));
|
||||
|
||||
expect(result.map(p => p.manifest.name)).toEqual(['utils', 'lib', 'tools']);
|
||||
});
|
||||
|
||||
test('should throw on circular dependencies', () => {
|
||||
expect(() => sortByDependencies(toPackages([
|
||||
{ name: 'a', version: '1.0.0', dependencies: { 'b': '^1.0.0' } },
|
||||
{ name: 'b', version: '1.0.0', dependencies: { 'a': '^1.0.0' } },
|
||||
]))).toThrow('Circular dependency');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { execCommand, getRootDir } from '@joplin/utils';
|
||||
import { satisfies } from 'compare-versions';
|
||||
import { copy, pathExists, readdir, readFile, remove, writeFile } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { chdir } from 'process';
|
||||
import yargs = require('yargs');
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
type Dependencies = Record<string, string>;
|
||||
|
||||
export interface PackageJson {
|
||||
name: string;
|
||||
version: string;
|
||||
private?: boolean;
|
||||
dependencies?: Dependencies;
|
||||
devDependencies?: Dependencies;
|
||||
peerDependencies?: Dependencies;
|
||||
optionalDependencies?: Dependencies;
|
||||
}
|
||||
|
||||
export interface PackageInfo {
|
||||
dir: string;
|
||||
manifest: PackageJson;
|
||||
}
|
||||
|
||||
const dependencyTypes = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const;
|
||||
|
||||
export const bumpPatch = (version: string) => {
|
||||
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!match) throw new Error(`Unsupported version format: ${version}`);
|
||||
return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
|
||||
};
|
||||
|
||||
// Same behaviour as `lerna version --no-private`: every public package gets a patch bump, and ranges
|
||||
// pointing to another public package are moved to its new version, keeping the `^` or `~` prefix.
|
||||
export const bumpVersions = (manifests: PackageJson[]) => {
|
||||
const oldVersions = new Map(manifests.map(manifest => [manifest.name, manifest.version]));
|
||||
|
||||
const updateRange = (name: string, range: string) => {
|
||||
const oldVersion = oldVersions.get(name);
|
||||
const match = range.match(/^([\^~]?)\d+\.\d+\.\d+$/);
|
||||
if (!oldVersion || !match || !satisfies(oldVersion, range)) return range;
|
||||
return `${match[1]}${bumpPatch(oldVersion)}`;
|
||||
};
|
||||
|
||||
return manifests.map(manifest => {
|
||||
const output: PackageJson = { ...manifest, version: bumpPatch(manifest.version) };
|
||||
for (const type of dependencyTypes) {
|
||||
const dependencies = manifest[type];
|
||||
if (!dependencies) continue;
|
||||
output[type] = Object.fromEntries(Object.entries(dependencies).map(([name, range]) => [name, updateRange(name, range)]));
|
||||
}
|
||||
return output;
|
||||
});
|
||||
};
|
||||
|
||||
// Dev dependencies are ignored because they don't affect consumers, and including them could create
|
||||
// cycles.
|
||||
export const sortByDependencies = (packages: PackageInfo[]) => {
|
||||
const remaining = new Map(packages.map(p => [p.manifest.name, p]));
|
||||
const output: PackageInfo[] = [];
|
||||
|
||||
const runtimeDependencies = ({ manifest }: PackageInfo) => {
|
||||
return Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies, ...manifest.optionalDependencies });
|
||||
};
|
||||
|
||||
while (remaining.size) {
|
||||
const ready = [...remaining.values()].filter(p => !runtimeDependencies(p).some(name => remaining.has(name)));
|
||||
if (!ready.length) throw new Error(`Circular dependency between: ${[...remaining.keys()].join(', ')}`);
|
||||
for (const p of ready) {
|
||||
output.push(p);
|
||||
remaining.delete(p.manifest.name);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
const loadPublicPackages = async (rootDir: string) => {
|
||||
const packagesDir = join(rootDir, 'packages');
|
||||
const output: PackageInfo[] = [];
|
||||
for (const name of await readdir(packagesDir)) {
|
||||
const dir = join(packagesDir, name);
|
||||
const path = join(dir, 'package.json');
|
||||
if (!await pathExists(path)) continue;
|
||||
const manifest: PackageJson = JSON.parse(await readFile(path, 'utf8'));
|
||||
if (!manifest.private) output.push({ dir, manifest });
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
const isPublished = async ({ name, version }: PackageJson) => {
|
||||
try {
|
||||
return await execCommand(['npm', 'view', `${name}@${version}`, 'version'], { quiet: true }) !== '';
|
||||
} catch (error) {
|
||||
if (String(error).includes('E404')) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const hasLicenseFile = async (dir: string) => {
|
||||
return (await readdir(dir)).some(name => /^(license|licence)(\..*)?$/i.test(name));
|
||||
};
|
||||
|
||||
// Like lerna, the root licence is temporarily copied to packages that don't have their own.
|
||||
const publishPackage = async (dir: string, rootLicensePath: string, dryRun: boolean) => {
|
||||
const tempLicensePath = await hasLicenseFile(dir) ? '' : join(dir, 'LICENSE');
|
||||
if (tempLicensePath) await copy(rootLicensePath, tempLicensePath);
|
||||
const previousDir = process.cwd();
|
||||
try {
|
||||
chdir(dir);
|
||||
// Without --workspaces=false, npm applies the root .gitignore to the package, which excludes
|
||||
// required files such as packages/lib/vendor/
|
||||
await execCommand(['npm', 'publish', '--workspaces=false', ...(dryRun ? ['--dry-run'] : [])]);
|
||||
} finally {
|
||||
chdir(previousDir);
|
||||
if (tempLicensePath) await remove(tempLicensePath);
|
||||
}
|
||||
};
|
||||
|
||||
const versionAll = async () => {
|
||||
const packages = await loadPublicPackages(await getRootDir());
|
||||
const bumped = bumpVersions(packages.map(p => p.manifest));
|
||||
for (const [index, { dir, manifest }] of packages.entries()) {
|
||||
await writeFile(join(dir, 'package.json'), `${JSON.stringify(bumped[index], null, 2)}\n`, 'utf8');
|
||||
console.info(`${manifest.name}: ${manifest.version} => ${bumped[index].version}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Packages are published one at a time, dependencies first, so that a failure leaves the registry in a
|
||||
// consistent state. Already published versions are skipped, so the command can be re-run after a failure.
|
||||
const publishAll = async (dryRun: boolean) => {
|
||||
const rootDir = await getRootDir();
|
||||
for (const { dir, manifest } of sortByDependencies(await loadPublicPackages(rootDir))) {
|
||||
const id = `${manifest.name}@${manifest.version}`;
|
||||
if (await isPublished(manifest)) {
|
||||
console.info(`Skipping ${id}: already published`);
|
||||
continue;
|
||||
}
|
||||
console.info(`Publishing ${id}...`);
|
||||
await publishPackage(dir, join(rootDir, 'LICENSE'), dryRun);
|
||||
}
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
void (async () => {
|
||||
try {
|
||||
await yargs(hideBin(process.argv))
|
||||
.command('version', 'Bumps the patch version of all public packages', {}, () => versionAll())
|
||||
.command('publish', 'Publishes all public packages whose version is not on the registry yet', yargs => {
|
||||
return yargs.option('dry-run', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
});
|
||||
}, args => publishAll(args['dry-run']))
|
||||
.demandCommand(1)
|
||||
.strict()
|
||||
.parseAsync();
|
||||
} catch (error) {
|
||||
console.error('Fatal error');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "1.0.70",
|
||||
"version": "1.0.71",
|
||||
"author": "Dom Christie",
|
||||
"main": "lib/turndown-plugin-gfm.cjs.js",
|
||||
"devDependencies": {
|
||||
@@ -30,7 +30,8 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laurent22/joplin-turndown-plugin-gfm.git"
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/turndown-plugin-gfm"
|
||||
},
|
||||
"scripts": {
|
||||
"build-all": "npm run build-cjs && npm run build-es && npm run build-iife",
|
||||
@@ -40,6 +41,5 @@
|
||||
"build-iife": "rollup -c config/rollup.config.iife.js",
|
||||
"build-test": "browserify test/turndown-plugin-gfm-test.js --outfile test/turndown-plugin-gfm-test.browser.js",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@joplin/turndown",
|
||||
"description": "A library that converts HTML to Markdown",
|
||||
"version": "4.0.88",
|
||||
"version": "4.0.89",
|
||||
"author": "Dom Christie",
|
||||
"main": "lib/turndown.cjs.js",
|
||||
"publishConfig": {
|
||||
@@ -34,7 +34,7 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laurent22/joplin.git",
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/turndown"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -46,6 +46,5 @@
|
||||
"build-test": "browserify test/turndown-test.js --outfile test/turndown-test.browser.js",
|
||||
"build": "npm run build-cjs",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"name": "@joplin/utils",
|
||||
"version": "3.7.3",
|
||||
"version": "3.7.4",
|
||||
"description": "Utilities for Joplin",
|
||||
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/utils",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/laurent22/joplin.git",
|
||||
"directory": "packages/utils"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.ts",
|
||||
@@ -90,7 +94,7 @@
|
||||
"author": "",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@joplin/fork-htmlparser2": "^4.1.66",
|
||||
"@joplin/fork-htmlparser2": "^4.1.67",
|
||||
"async-mutex": "0.5.0",
|
||||
"dayjs": "1.11.21",
|
||||
"execa": "5.1.1",
|
||||
@@ -109,6 +113,5 @@
|
||||
"jest": "29.7.0",
|
||||
"ts-jest": "29.4.12",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"gitHead": "898d2999f93fbedf0ce5f6626feb63e1a732ce0a"
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,15 @@ If you provide a configuration and you receive "success!" on the "check config"
|
||||
- Region: Anything you want to type, can't be left empty
|
||||
- Force Path Style: unchecked
|
||||
|
||||
## OVHCloud
|
||||
|
||||
- URL: `https://s3.<region>.io.cloud.ovh.net/` (This is the endpoint URL provided by OVHCloud)
|
||||
- Region: required
|
||||
- Force Path Style: unchecked
|
||||
|
||||
> [!NOTE]
|
||||
> Uploading the bucket policy to OVHCloud may result in an error which can be resolved by removing the permission `s3:DeleteObjectVersion`. The error may occur even with the bucket versioning enabled as it is not currently a supported action. Omit this permission from the bucket policy. Refer to the [OVHCloud documentation](https://docs.ovhcloud.com/en/guides/storage-and-backup/object-storage/s3-identity-and-access-management) for all supported actions.
|
||||
|
||||
## Scaleway
|
||||
- URL: `https://s3.<region>.scw.cloud` (The region is the same as the bucket's region. You could just copy your bucket endpoint in the bucket settings tab, then remove the bucket name from it.)
|
||||
- Region: required (example: `fr-par`)
|
||||
|
||||
@@ -34,7 +34,9 @@ Unlike the mobile or desktop application, the CLI app doesn't bundle its depende
|
||||
|
||||
yarn publishAll
|
||||
|
||||
This is going to publish all the Joplin libraries, such as `@joplin/lib`, `@joplin/tools`, etc.
|
||||
This is going to bump the version of all the Joplin libraries, such as `@joplin/lib`, `@joplin/tools`, etc., then commit and push a `sub-packages-v*` tag. The tag triggers the `publish-sub-packages.yml` workflow, which builds and publishes the libraries to npm using [trusted publishing](https://docs.npmjs.com/trusted-publishers). Wait for that workflow to complete before releasing the CLI app.
|
||||
|
||||
Each library must have `laurent22/joplin` and `publish-sub-packages.yml` registered as a trusted publisher on npmjs.com, with "Allow npm publish" enabled. When adding a new library, it must be published once manually before this can be configured.
|
||||
|
||||
Then in `app-cli/package.json`, all `@joplin` dependencies and devdependencies must be set to the last major/minor version. For example:
|
||||
|
||||
|
||||
Reference in new issue
Block a user