1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-02-01 07:49:31 +02:00

Compare commits

...

46 Commits

Author SHA1 Message Date
renovate[bot]
8b5288e487 Update dependency electron-updater to v6.6.8 2026-02-01 01:58:48 +00:00
rnbastos
7651d8e3c4 All: Translation: Update pt_BR.po (#14238) 2026-01-31 17:23:25 -05:00
renovate[bot]
d5c72c13cb Update dependency @types/serviceworker to v0.0.164 (#14237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 09:49:32 +00:00
renovate[bot]
4377634e7b Update dependency esbuild to v0.25.12 (#14236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 04:59:05 +00:00
renovate[bot]
69ec5c7f86 Update dependency @types/serviceworker to v0.0.163 (#14234)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-30 17:50:22 +00:00
renovate[bot]
f02b0f48d8 Update dependency react-refresh to v0.18.0 (#14233)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-30 14:33:32 +01:00
renovate[bot]
4d77c1385f Update dependency sass to v1.93.3 (#14228)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 16:51:56 +00:00
renovate[bot]
c83f9ddeac Update dependency dayjs to v1.11.19 (#14227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 14:49:50 +00:00
renovate[bot]
1b9c11df7b Update dependency @types/serviceworker to v0.0.162 (#14225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 09:28:36 +00:00
Nick
333a8723e8 All: Translation: Update sv.po (#14220) 2026-01-28 18:14:28 -05:00
Laurent Cozic
e030c8271d Chore: Try to fix app-desktop tests on local 2026-01-28 12:55:08 +00:00
renovate[bot]
560bc31445 Update dependency gettext-extractor to v4.0.1 (#14217)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-28 01:45:26 +00:00
renovate[bot]
c71aeb74b2 Update dependency gettext-extractor to v4 (#14213)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 23:36:41 +00:00
renovate[bot]
ffaf2acb66 Update dependency @rollup/plugin-replace to v6.0.3 (#14212)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 18:16:02 +00:00
renovate[bot]
f442f1fb23 Update dependency @types/serviceworker to v0.0.161 (#14206)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 11:12:24 +00:00
renovate[bot]
81a1451820 Update dependency react-native-safe-area-context to v5.6.2 (#14202)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 02:16:37 +00:00
renovate[bot]
b3a3d71461 Update dependency @react-native-community/datetimepicker to v8.4.7 (#14191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 22:47:00 +00:00
bwat47
1db38c3232 Desktop, Mobile: Fixes #13933: Markdown editor: Scroll jumps in notes with many inline images (#13955) 2026-01-26 15:21:08 +00:00
Fardin96
42e645eb70 Mobile: Fixes #13243: Align tag search-input-clear behavior across input methods (#14042) 2026-01-26 15:12:06 +00:00
mrjo118
3860f44d06 Mobile: Fixes #14153: Prevent the back button sometimes disappearing when switching between editors (#14164) 2026-01-26 15:09:37 +00:00
Henry Heino
4df0f8668d Desktop,Mobile: Resolves #14158: Markdown Editor: Make code block highlighting closer to the viewer (#14168) 2026-01-26 15:06:37 +00:00
Henry Heino
306d0fddd8 Desktop: OneNote import: Import invalid attachments as empty attachments (#14177) 2026-01-26 15:06:24 +00:00
Henry Heino
56d12b28f2 All: Unlinked resource deletion: Fix resources attached only via reference links are auto-deleted (#14178) 2026-01-26 15:06:15 +00:00
Henry Heino
6c5ea4872a Desktop,Mobile: Markdown editor: Fix error logged in "hide markdown" mode for certain markup (#14179) 2026-01-26 15:06:06 +00:00
Henry Heino
9856e8ae93 Chore: Sync fuzzer: Test adding, removing resources from notes (#14185) 2026-01-26 15:05:50 +00:00
Henry Heino
5712da4c0f Desktop,Mobile: Fixes #14009: Markdown editor: Upgrade most CodeMirror dependencies (#14186) 2026-01-26 15:04:35 +00:00
Henry Heino
4f7ee56444 Desktop: Fixes #13793: Make conflicts caused by resource duplication less likely (#14188) 2026-01-26 15:04:26 +00:00
Laurent Cozic
8e2b6ca296 Chore: Improved performance log consistency, and log to console on Android production too 2026-01-26 14:47:19 +00:00
Laurent Cozic
0172bb0ad8 Chore: Fix error in release-android script when the main apk is not built 2026-01-26 14:46:33 +00:00
Laurent Cozic
1d38e443ba Chore: Do not create GitHub release when there is no APK to publish 2026-01-26 13:58:57 +00:00
Laurent Cozic
5ad19b7261 Chore: Make Android app profileable 2026-01-26 13:46:27 +00:00
Arda Kılıçdağı
70293478a2 All: Translation: Update tr_TR.po (#14193) 2026-01-25 18:14:39 -05:00
custiq
3aaa20254f All: Translation: Update fi_FI.po (#14189) 2026-01-24 23:46:32 -05:00
renovate[bot]
42c248f7ca Update dependency @types/serviceworker to v0.0.160 (#14190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-24 09:11:54 +00:00
Joplin Bot
ac1e94a8df Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-01-23 06:47:06 +00:00
renovate[bot]
daff4496cf Update dependency turndown to v7.2.2 (#14181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-23 02:15:02 +00:00
Joplin Bot
1e00078228 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-01-23 01:56:25 +00:00
renovate[bot]
03a1de9370 Update dependency @rollup/plugin-commonjs to v28.0.9 (#14175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 17:46:44 +00:00
renovate[bot]
55ef256c65 Update dependency rate-limiter-flexible to v7.4.0 (#14174)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 17:46:38 +00:00
renovate[bot]
6d115db16f Update dependency @types/yargs to v17.0.34 (#14173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 16:25:50 +00:00
renovate[bot]
5853031fde Update dependency @types/serviceworker to v0.0.159 (#14172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 09:05:01 +00:00
renovate[bot]
47db2ae962 Update dependency @types/nodemailer to v6.4.21 (#14171)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 09:02:54 +00:00
Laurent Cozic
b960a2a8b0 Doc: Updated JSB contact link 2026-01-21 09:27:55 +00:00
renovate[bot]
fcaa7d2a98 Update dependency lint-staged to v16.2.6 (#14165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 18:18:59 +00:00
renovate[bot]
99284ae135 Update dependency lint-staged to v16.2.0 (#14162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 13:20:03 +00:00
Joplin Bot
66ae58c81b Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-01-19 18:43:09 +00:00
52 changed files with 1595 additions and 1204 deletions

View File

@@ -1851,17 +1851,22 @@ packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/model/ResourceRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/ProgressBar.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
packages/tools/fuzzer/utils/diffSortedStringArrays.js
packages/tools/fuzzer/utils/extractResourceIds.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/hangingIndent.js
packages/tools/fuzzer/utils/logDiffDebug.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/randomId.test.js
packages/tools/fuzzer/utils/randomId.js
packages/tools/fuzzer/utils/randomString.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js

5
.gitignore vendored
View File

@@ -1825,17 +1825,22 @@ packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/model/ResourceRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/ProgressBar.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
packages/tools/fuzzer/utils/diffSortedStringArrays.js
packages/tools/fuzzer/utils/extractResourceIds.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/hangingIndent.js
packages/tools/fuzzer/utils/logDiffDebug.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/randomId.test.js
packages/tools/fuzzer/utils/randomId.js
packages/tools/fuzzer/utils/randomString.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js

View File

@@ -86,7 +86,7 @@
"gulp": "4.0.2",
"husky": "9.1.7",
"lerna": "3.22.1",
"lint-staged": "16.1.6",
"lint-staged": "16.2.6",
"madge": "8.0.0",
"npm-package-json-lint": "9.0.0",
"typescript": "5.8.3"

View File

@@ -131,7 +131,9 @@ module.exports = {
testEnvironment: 'jsdom',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
testEnvironmentOptions: {
customExportConditions: ['node', 'require'],
},
// Adds a location field to test results
// testLocationInResults: false,

View File

@@ -169,7 +169,7 @@
"debounce": "1.2.1",
"electron": "39.2.3",
"electron-builder": "24.13.3",
"electron-updater": "6.6.2",
"electron-updater": "6.6.8",
"electron-window-state": "5.0.3",
"esbuild": "^0.25.3",
"formatcoords": "1.1.3",

View File

@@ -0,0 +1,90 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = notarizeFile;
const fs_1 = require('fs');
const notarize_1 = require('@electron/notarize');
const execCommand = require('./execCommand');
const child_process_1 = require('child_process');
const util_1 = require('util');
const execAsync = (0, util_1.promisify)(child_process_1.exec);
// Same appId in electron-builder.
const appId = 'net.cozic.joplin-desktop';
function isDesktopAppTag(tagName) {
if (!tagName) { return false; }
return tagName[0] === 'v';
}
async function notarizeFile(filePath) {
if (process.platform !== 'darwin') { return; }
console.info(`Checking if notarization should be done on: ${filePath}`);
if (!process.env.IS_CONTINUOUS_INTEGRATION || !isDesktopAppTag(process.env.GIT_TAG_NAME)) {
console.info(`Either not running in CI or not processing a desktop app tag - skipping notarization. process.env.IS_CONTINUOUS_INTEGRATION = ${process.env.IS_CONTINUOUS_INTEGRATION}; process.env.GIT_TAG_NAME = ${process.env.GIT_TAG_NAME}`);
return;
}
if (!process.env.APPLE_ID || !process.env.APPLE_ID_PASSWORD) {
console.warn('Environment variables APPLE_ID and APPLE_ID_PASSWORD not found - notarization will NOT be done.');
return;
}
if (!(0, fs_1.existsSync)(filePath)) {
throw new Error(`Cannot find file at: ${filePath}`);
}
// Every x seconds we print something to stdout, otherwise CI may timeout
// the task after 10 minutes, and Apple notarization can take more time.
const waitingIntervalId = setInterval(() => {
console.info('.');
}, 60000);
const isPkg = filePath.endsWith('.pkg');
console.info(`Notarizing ${filePath}`);
try {
if (isPkg) {
await execAsync(`xcrun notarytool submit "${filePath}" ` +
`--apple-id "${process.env.APPLE_ID}" ` +
`--password "${process.env.APPLE_ID_PASSWORD}" ` +
`--team-id "${process.env.APPLE_ASC_PROVIDER}" ` +
'--wait', { maxBuffer: 1024 * 1024 });
} else {
await (0, notarize_1.notarize)({
appBundleId: appId,
appPath: filePath,
// Apple Developer email address
appleId: process.env.APPLE_ID,
// App-specific password: https://support.apple.com/en-us/HT204397
appleIdPassword: process.env.APPLE_ID_PASSWORD,
// When Apple ID is attached to multiple providers (eg if the
// account has been used to build multiple apps for different
// companies), in that case the provider "Team Short Name" (also
// known as "ProviderShortname") must be provided.
//
// Use this to get it:
//
// xcrun altool --list-providers -u APPLE_ID -p APPLE_ID_PASSWORD
// ascProvider: process.env.APPLE_ASC_PROVIDER,
// In our case, the team ID is the same as the legacy ASC_PROVIDER
teamId: process.env.APPLE_ASC_PROVIDER,
tool: 'notarytool',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
});
}
} catch (error) {
console.error(error);
process.exit(1);
}
clearInterval(waitingIntervalId);
// It appears that electron-notarize doesn't staple the app, but without
// this we were still getting the malware warning when launching the app.
// Stapling the app means attaching the notarization ticket to it, so that
// if the user is offline, macOS can still check if the app was notarized.
// So it seems to be more or less optional, but at least in our case it
// wasn't.
console.info('Stapling notarization ticket to the file...');
const staplerCmd = `xcrun stapler staple "${filePath}"`;
console.info(`> ${staplerCmd}`);
console.info(await execCommand(staplerCmd));
console.info(`Validating stapled file: ${filePath}`);
try {
await execAsync(`spctl -a -vv -t install "${filePath}"`);
} catch (error) {
console.error(`Failed validating stapled file: ${filePath}:`, error);
}
console.info(`Done notarizing ${filePath}`);
}
// # sourceMappingURL=notarizeFile.js.map

View File

@@ -132,6 +132,17 @@ android {
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
profileable {
// Release-like build that allows profiling with Android Studio Profiler
initWith release
signingConfig signingConfigs.debug
// Required for Android Studio Profiler to attach
debuggable false
// Keeps symbols for better stack traces in profiler
minifyEnabled false
// Use release variants of dependencies that don't have profileable
matchingFallbacks = ['release']
}
}
}

View File

@@ -46,6 +46,9 @@
android:theme="@style/AppTheme"
android:supportsRtl="true">
<!-- Enable profiling in release builds (Android 10+) -->
<profileable android:shell="true" />
<!--
2018-12-16: Changed android:launchMode from "singleInstance" to "singleTop" for Firebase notification
Previously singleInstance was necessary to prevent multiple instance of the RN app from running at the same time, but maybe no longer needed.

View File

@@ -438,9 +438,8 @@ const useInputEventHandlers = ({
const onSubmit = useCallback(() => {
if (selectedResult) {
onItemSelected(selectedResult, selectedIndex);
setSearch('');
}
}, [onItemSelected, selectedResult, selectedIndex, setSearch]);
}, [onItemSelected, selectedResult, selectedIndex]);
// For now, onKeyPress only works on web.
// See https://github.com/react-native-community/discussions-and-proposals/issues/249

View File

@@ -694,10 +694,17 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
</Menu>
);
// Updating the state of this component can result in the left most element becoming hidden, so add a dummy as the first element to prevent this
// See https://github.com/laurent22/joplin/issues/14153
const zeroWidthSpacer = (
<View style={{ width: 0 }} pointerEvents="none"/>
);
return (
<View style={this.styles().outerContainer}>
<View style={this.styles().aboveHeader}/>
<View style={this.styles().innerContainer}>
{zeroWidthSpacer}
{sideMenuComp}
{backButtonComp}
{renderUndoButton()}

View File

@@ -30,7 +30,7 @@
"@joplin/utils": "~3.6",
"@js-draw/material-icons": "1.33.0",
"@react-native-clipboard/clipboard": "1.16.3",
"@react-native-community/datetimepicker": "8.4.5",
"@react-native-community/datetimepicker": "8.4.7",
"@react-native-community/geolocation": "3.4.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-community/push-notification-ios": "1.11.0",
@@ -73,7 +73,7 @@
"react-native-quick-actions": "0.3.13",
"react-native-quick-crypto": "0.7.17",
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.6.1",
"react-native-safe-area-context": "5.6.2",
"react-native-securerandom": "1.0.1",
"react-native-share": "12.2.0",
"react-native-sqlite-storage": "6.0.1",
@@ -114,13 +114,13 @@
"@types/node": "18.19.130",
"@types/react": "19.0.14",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.158",
"@types/serviceworker": "0.0.164",
"@types/tar-stream": "3.1.4",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.21.2",
"esbuild": "0.25.11",
"esbuild": "0.25.12",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.3.2",
"gulp": "4.0.2",
@@ -132,7 +132,7 @@
"punycode": "2.3.1",
"react-dom": "19.0.0",
"react-native-web": "0.21.2",
"react-refresh": "0.17.0",
"react-refresh": "0.18.0",
"react-test-renderer": "19.0.0",
"sharp": "0.34.4",
"sqlite3": "5.1.6",

View File

@@ -1,6 +1,6 @@
import PluginAssetsLoader from '../PluginAssetsLoader';
import AlarmService from '@joplin/lib/services/AlarmService';
import Logger, { TargetType } from '@joplin/utils/Logger';
import Logger, { LogLevel, TargetType } from '@joplin/utils/Logger';
import BaseModel from '@joplin/lib/BaseModel';
import BaseService from '@joplin/lib/services/BaseService';
import ResourceService from '@joplin/lib/services/ResourceService';
@@ -200,11 +200,8 @@ const buildStartupTasks = (
const mainLogger = new Logger();
mainLogger.addTarget(TargetType.Database, { database: logDatabase, source: 'm' });
mainLogger.setLevel(Logger.LEVEL_INFO);
if (Setting.value('env') === 'dev') {
mainLogger.addTarget(TargetType.Console);
mainLogger.setLevel(Logger.LEVEL_DEBUG);
}
mainLogger.addTarget(TargetType.Console);
mainLogger.setLevel(Setting.value('env') === 'dev' ? LogLevel.Debug : LogLevel.Info);
Logger.initializeGlobalLogger(mainLogger);
initLib(mainLogger);

View File

@@ -13,7 +13,7 @@
"url": "git+https://github.com/laurent22/joplin.git"
},
"devDependencies": {
"@types/yargs": "17.0.33",
"@types/yargs": "17.0.34",
"joplin-plugin-freehand-drawing": "4.3.0",
"ts-node": "10.9.2",
"typescript": "5.8.3"

View File

@@ -6,6 +6,33 @@ import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
const imageClassName = 'cm-md-image';
class ImageHeightCache {
private readonly cache = new Map<string, number>();
private readonly maxEntries = 500;
public get(key: string): number | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Refresh recency
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
public set(key: string, height: number): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxEntries) {
const firstKey = this.cache.keys().next().value;
if (firstKey) this.cache.delete(firstKey);
}
this.cache.set(key, height);
}
}
const imageHeightCache = new ImageHeightCache();
class ImageWidget extends WidgetType {
private resolvedSrc_: string;
@@ -41,9 +68,16 @@ class ImageWidget extends WidgetType {
const updateImageUrl = () => {
if (this.resolvedSrc_) {
// Use a background-image style property rather than img[src=]. This
// simplifies setting the image to the correct size/position.
image.src = this.resolvedSrc_;
// When the image loads, measure and cache the height
image.onload = () => {
// Measure container height (what CodeMirror uses for scroll calculations).
if (dom.isConnected) {
imageHeightCache.set(this.cacheKey, dom.offsetHeight);
}
dom.style.minHeight = '';
};
}
};
@@ -56,10 +90,16 @@ class ImageWidget extends WidgetType {
updateImageUrl();
}
// Apply cached height as min-height to prevent collapse during load.
const cached = imageHeightCache.get(this.cacheKey);
if (cached) {
dom.style.minHeight = `${cached}px`;
}
return true;
}
public toDOM() {
public toDOM(_view: EditorView) {
const container = document.createElement('div');
container.classList.add(imageClassName);
@@ -72,8 +112,12 @@ class ImageWidget extends WidgetType {
return container;
}
private get cacheKey() {
return `${this.src_}_${this.width_ ?? ''}_${this.reloadCounter_}`;
}
public get estimatedHeight() {
return -1;
return imageHeightCache.get(this.cacheKey) ?? -1;
}
}

View File

@@ -14,13 +14,18 @@ const createEditor = async (initialMarkdown: string, expectedTags: string[] = ['
describe('replaceInlineHtml', () => {
test.each([
{ markdown: '<sup>Test</sup>', expectedTagsQuery: 'sup' },
{ markdown: '<strike>Test</strike>', expectedTagsQuery: 'strike' },
{ markdown: 'Test: <span style="color: red;">Test</span>', expectedTagsQuery: 'span[style]' },
{ markdown: 'Test: <span style="color: rgb(123, 0, 0);">Test</span>', expectedTagsQuery: 'span[style]' },
])('should render inline HTML (case %#)', async ({ markdown, expectedTagsQuery }) => {
{ markdown: '<sup>Test</sup>', expectedDomTags: 'sup' },
{ markdown: '<strike>Test</strike>', expectedDomTags: 'strike' },
{ markdown: 'Test: <span style="color: red;">Test</span>', expectedDomTags: 'span[style]' },
{ markdown: 'Test: <span style="color: rgb(123, 0, 0);">Test</span>', expectedDomTags: 'span[style]' },
{
markdown: '<sup>Test *test*...</sup>',
expectedDomTags: 'sup',
initialSyntaxTags: ['HTMLTag', 'Emphasis'],
},
])('should render inline HTML (case %#)', async ({ markdown, expectedDomTags: expectedTagsQuery, initialSyntaxTags }) => {
// Add additional newlines: Ensure that the cursor isn't initially on the same line as the content to be rendered:
const editor = await createEditor(`\n\n${markdown}\n\n`);
const editor = await createEditor(`\n\n${markdown}\n\n`, initialSyntaxTags);
expect(editor.contentDOM.querySelector(expectedTagsQuery)).toBeTruthy();
});

View File

@@ -31,9 +31,9 @@ const createHtmlReplacementExtension = (tagName: string, onRenderContent: OnRend
// Find the matching closing tag
for (; !!cursor && nestedTagCounter > 0; cursor = cursor.nextSibling) {
const info = htmlNodeInfo(cursor, state);
if (isMatchingOpeningTag(info)) {
if (info && isMatchingOpeningTag(info)) {
nestedTagCounter ++;
} else if (isMatchingClosingTag(info)) {
} else if (info && isMatchingClosingTag(info)) {
nestedTagCounter --;
}

View File

@@ -139,25 +139,7 @@ const createTheme = (theme: EditorTheme): Extension[] => {
},
'& .cm-codeBlock': {
'&.cm-regionFirstLine, &.cm-regionLastLine': {
borderRadius: '3px',
},
'&:not(.cm-regionFirstLine)': {
borderTop: 'none',
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
'&:not(.cm-regionLastLine)': {
borderBottom: 'none',
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
borderWidth: '1px',
borderStyle: 'solid',
borderColor: theme.colorFaded,
backgroundColor: 'rgba(155, 155, 155, 0.1)',
backgroundColor: 'rgba(155, 155, 155, 0.07)',
...monospaceStyle,
},
@@ -269,8 +251,8 @@ const createTheme = (theme: EditorTheme): Extension[] => {
},
{
tag: tags.comment,
opacity: 0.9,
fontStyle: 'italic',
color: isDarkTheme ? '#b18eb1' : '#6d7086',
},
{
tag: tags.link,
@@ -281,26 +263,23 @@ const createTheme = (theme: EditorTheme): Extension[] => {
fontStyle: 'italic',
},
// Content of code blocks
// Content of code blocks. This should roughly match the colors used by the default
// highlight.js theme in the note viewer, while also preserving at least 4.5:1 contrast.
{
tag: tags.keyword,
color: isDarkTheme ? '#ff7' : '#740',
},
{
tag: tags.operator,
color: isDarkTheme ? '#f7f' : '#805',
color: isDarkTheme ? '#F92672' : '#a626a4',
},
{
tag: tags.literal,
color: isDarkTheme ? '#aaf' : '#037',
},
{
tag: tags.operator,
color: isDarkTheme ? '#fa9' : '#490',
tag: tags.number,
color: isDarkTheme ? '#d19a66' : '#986801',
},
{
tag: tags.typeName,
color: isDarkTheme ? '#7ff' : '#a00',
color: isDarkTheme ? '#d19a66' : '#986801',
},
{
tag: tags.inserted,
@@ -312,13 +291,21 @@ const createTheme = (theme: EditorTheme): Extension[] => {
},
{
tag: tags.propertyName,
color: isDarkTheme ? '#d96' : '#940',
color: isDarkTheme ? '#61aeee' : '#406be5',
},
{
tag: tags.string,
color: isDarkTheme ? '#98c379' : '#50a14f',
},
{
// CSS class names (and class names in other languages)
tag: tags.className,
color: isDarkTheme ? '#d8a' : '#904',
},
{
tag: tags.macroName,
color: isDarkTheme ? '#e6c07b' : '#986801',
},
]);
return [

View File

@@ -28,21 +28,21 @@
"typescript": "5.8.3"
},
"dependencies": {
"@codemirror/autocomplete": "6.18.3",
"@codemirror/commands": "6.7.1",
"@codemirror/lang-html": "6.4.9",
"@codemirror/lang-markdown": "6.3.1",
"@codemirror/autocomplete": "6.20.0",
"@codemirror/commands": "6.10.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/language": "6.10.4",
"@codemirror/language-data": "6.3.1",
"@codemirror/legacy-modes": "6.4.2",
"@codemirror/lint": "6.8.3",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/lint": "6.9.2",
"@codemirror/search": "6.5.8",
"@codemirror/state": "6.4.1",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.35.0",
"@joplin/fork-uslug": "^2.0.0",
"@lezer/common": "1.2.3",
"@lezer/highlight": "1.2.1",
"@lezer/markdown": "1.3.2",
"@lezer/common": "1.5.0",
"@lezer/highlight": "1.2.3",
"@lezer/markdown": "1.6.3",
"@replit/codemirror-vim": "6.2.1",
"dompurify": "3.2.7",
"orderedmap": "2.1.1",

View File

@@ -130,7 +130,7 @@ export default class PerformanceLogger {
const startTime = performance.now();
this.lastLogTime_ = startTime;
PerformanceLogger.logDebug_(`${name}: Start at ${formatAbsoluteTime(startTime)}`);
PerformanceLogger.log_(`${name}: Start at ${formatAbsoluteTime(startTime)}`);
const onEnd = () => {
const now = performance.now();
@@ -140,12 +140,7 @@ export default class PerformanceLogger {
performance.measure(name, `${uniqueTaskId}-start`, `${uniqueTaskId}-end`);
}
const duration = now - startTime;
// Increase the log level for long-running tasks
const isLong = duration >= Second / 10;
const log = isLong ? PerformanceLogger.log_ : PerformanceLogger.logDebug_;
log(`${name}: End at ${formatAbsoluteTime(now)} (took ${formatTaskDuration(now - startTime)})`);
PerformanceLogger.log_(`${name}: End at ${formatAbsoluteTime(now)} (took ${formatTaskDuration(now - startTime)})`);
};
return {
onEnd,

View File

@@ -520,6 +520,50 @@ describe('models/Folder.sharing', () => {
expect(note4.user_updated_time).toBe(userUpdatedTimes[note4.id]);
});
it('should prefer duplicating resources in unshared folders to shared folders', async () => {
const resourceService = new ResourceService();
const folder1 = await createFolderTree('', [
{
title: 'folder 1', // Share 1
children: [
{
title: 'note 1',
},
],
},
{
title: 'folder 2', // Not shared
children: [
{
title: 'note 2',
},
],
},
]);
let note1: NoteEntity = await Note.loadByTitle('note 1');
let note2: NoteEntity = await Note.loadByTitle('note 2');
await Folder.save({ id: folder1.id, share_id: 'share1' });
note1 = await shim.attachFileToNote(note1, testImagePath);
note2 = await Note.save({ id: note2.id, body: note1.body });
await msleep(1);
await resourceService.indexNoteResources(); // Populate note_resources
await Folder.updateAllShareIds(resourceService, []);
// After
expect(await Resource.all()).toHaveLength(2);
// note1 should have the same body
expect(await Note.load(note1.id)).toMatchObject({ body: note1.body, share_id: 'share1' });
// note2's body should be updated
expect(await Note.load(note2.id)).not.toMatchObject({ body: note2.body, share_id: '' });
});
it('should clear share_ids for items that are no longer part of an existing share', async () => {
await createFolderTree('', [
{

View File

@@ -639,12 +639,21 @@ export default class Folder extends BaseItem {
// one note. If it is not, we create duplicate resources so that
// each note has its own separate resource.
// Order unshared items first: This makes conflicts less likely, since shared
// items are more likely to be duplicated by multiple users.
const orderingSql = 'ORDER BY is_shared ASC';
const noteResourceAssociations = await this.db().selectAll(`
SELECT resource_id, note_id, notes.share_id
SELECT
resource_id,
note_id,
notes.share_id,
(notes.share_id != '') AS is_shared
FROM note_resources
LEFT JOIN notes ON notes.id = note_resources.note_id
WHERE resource_id IN (${this.escapeIdsForSql(resourceIds)})
AND is_associated = 1
${orderingSql}
`) as NoteResourceRow[];
const resourceIdToNotes: Record<string, NoteResourceRow[]> = {};

View File

@@ -48,7 +48,20 @@ describe('services/ResourceService', () => {
expect(!(await NoteResource.all()).length).toBe(true);
}));
it('should not delete resource if still associated with at least one note', (async () => {
it.each([
{
linkStyle: 'image 1',
markupTag: (id: string) => `![image](:/${id})`,
},
{
linkStyle: 'image 2',
markupTag: (id: string) => `![image][image]\n\n[image]: :/${id}`,
},
{
linkStyle: 'html link',
markupTag: (id: string) => `<a href=":/${id}">test</a>`,
},
])('should not delete resource if still associated with at least one note (link style: $linkStyle)', (async ({ markupTag }) => {
const service = new ResourceService();
const folder1 = await Folder.save({ title: 'folder1' });
@@ -63,7 +76,7 @@ describe('services/ResourceService', () => {
await service.indexNoteResources();
await Note.save({ id: note2.id, body: Resource.markupTag(resource1) });
await Note.save({ id: note2.id, body: markupTag(resource1.id) });
await service.indexNoteResources();

View File

@@ -79,6 +79,7 @@ describe('urlUtils', () => {
['Bla [](:/11111111111111111111111111111111 "Some title") bla [](:/22222222222222222222222222222222 "something else") bla', ['11111111111111111111111111111111', '22222222222222222222222222222222']],
['Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla [](:/22222222222222222222222222222222) bla', ['fcca2938a96a22570e8eae2565bc6b0b', '22222222222222222222222222222222']],
['Bla <img src=":/fcca2938a96a22570e8eae2565bc6b0b"/> bla <a href=":/33333333333333333333333333333333"/>Some note link</a> blu [](:/22222222222222222222222222222222) bla', ['fcca2938a96a22570e8eae2565bc6b0b', '33333333333333333333333333333333', '22222222222222222222222222222222']],
['Link to [a test note] and [another] note.\n\n[a test note]: :/fcca2938a96a22570e8eae2565bc6b0b\n[another]: :/f04a2938a26822570e8eae2505bc6b0c', ['fcca2938a96a22570e8eae2565bc6b0b', 'f04a2938a26822570e8eae2505bc6b0c']],
['nothing here', []],
['', []],
];

View File

@@ -94,13 +94,20 @@ export const fileUrlToResourceUrl = (fileUrl: string, resourceDir: string) => {
};
export const extractResourceUrls = (text: string) => {
const markdownLinksRE = /\]\((.*?)\)/g;
const markdownLinkRegexes = [
// Standard [link](...)-style links
/\]\((.*?)\)/g,
// Reference links
/\]:(.*?)(?:[\n]|$)/g,
];
const output = [];
let result = null;
while ((result = markdownLinksRE.exec(text)) !== null) {
const resourceUrlInfo = parseResourceUrl(result[1]);
if (resourceUrlInfo) output.push(resourceUrlInfo);
for (const regex of markdownLinkRegexes) {
while ((result = regex.exec(text)) !== null) {
const resourceUrlInfo = parseResourceUrl(result[1].trim());
if (resourceUrlInfo) output.push(resourceUrlInfo);
}
}
const htmlRegexes = [

View File

@@ -459,7 +459,7 @@ export function getPlans(stripeConfig: StripePublicConfig): Record<PlanName, Pla
featureLabelsOn: getFeatureLabelsByPlan(PlanName.JoplinServerBusiness, true),
featureLabelsOff: getFeatureLabelsByPlan(PlanName.JoplinServerBusiness, false),
cfaLabel: _('Get a quote'),
cfaUrl: 'mailto:jsb-inquiry@joplin.cloud?subject=Joplin%20Server%20Business%20inquiry',
cfaUrl: 'https://tally.so/r/D4BlOE',
footnote: '',
learnMoreUrl: 'https://joplinapp.org/help/apps/joplin_server_business',
hostingType: PlanHostingType.Self,

View File

@@ -767,14 +767,9 @@ impl AttachmentInfo {
.into())
} else if self.data_ref.starts_with("<invfdo>") {
// "invalid"
log_warn!("Attempted to load an invalid {} file", self.extension);
Err(parser_error!(
ResolutionFailed,
"Unable to load invalid file reference: {} (ext: {})",
self.data_ref,
self.extension
)
.into())
log_warn!("Attempted to load an invalid {} file. Importing an empty file.", self.extension);
// Return empty data
Ok(FileBlob::default())
} else {
Err(parser_error!(
ResolutionFailed,

View File

@@ -34,7 +34,7 @@
"bcryptjs": "2.4.3",
"bulma": "1.0.4",
"compare-versions": "6.1.1",
"dayjs": "1.11.18",
"dayjs": "1.11.19",
"formidable": "2.1.2",
"fs-extra": "11.3.2",
"html-entities": "1.4.0",
@@ -54,7 +54,7 @@
"prettycron": "0.10.0",
"qrcode": "1.5.4",
"query-string": "7.1.3",
"rate-limiter-flexible": "7.3.2",
"rate-limiter-flexible": "7.4.0",
"raw-body": "3.0.1",
"samlify": "2.10.1",
"sqlite3": "5.1.6",
@@ -77,8 +77,8 @@
"@types/mustache": "4.2.6",
"@types/node": "18.19.130",
"@types/node-os-utils": "1.3.4",
"@types/nodemailer": "6.4.20",
"@types/yargs": "17.0.33",
"@types/nodemailer": "6.4.21",
"@types/yargs": "17.0.34",
"@types/zxcvbn": "4.4.5",
"gulp": "4.0.2",
"jest": "29.7.0",

View File

@@ -1,7 +1,6 @@
import uuid from '@joplin/lib/uuid';
import Client from './Client';
import ClientPool from './ClientPool';
import { assertIsFolder, assertIsNote, FuzzContext, ItemId, RandomFolderOptions } from './types';
import { assertIsFolder, assertIsNote, FuzzContext, ItemId, RandomFolderOptions, ResourceData } from './types';
import { strict as assert } from 'assert';
import Logger from '@joplin/utils/Logger';
import retryWithCount from './utils/retryWithCount';
@@ -31,10 +30,12 @@ export default class ActionRunner {
await this.clientPool_.checkState();
}, {
count: 4,
delayOnFailure: count => count * Second * 2,
onFail: async () => {
logger.info('.checkState failed. Syncing all clients...');
await this.clientPool_.syncAll();
delayOnFailure: count => count * Second * 3,
onFail: async ({ willRetry }) => {
if (willRetry) {
logger.info('.checkState failed. Syncing all clients...');
await this.clientPool_.syncAll();
}
},
});
}
@@ -148,7 +149,7 @@ const getActions = (context: FuzzContext, clientPool: ClientPool, client: Client
// Create a toplevel folder to serve as this
// folder's parent if none exist yet
if (!parentId) {
parentId = uuid.create();
parentId = context.randomId();
await client.createFolder({
parentId: '',
id: parentId,
@@ -171,7 +172,7 @@ const getActions = (context: FuzzContext, clientPool: ClientPool, client: Client
await client.createNote({
...defaultNoteProperties,
parentId: await selectOrCreateWriteableFolder(),
id: uuid.create(),
id: context.randomId(),
title: 'Test note',
body: 'Body',
});
@@ -184,12 +185,16 @@ const getActions = (context: FuzzContext, clientPool: ClientPool, client: Client
};
const noteById = (id: ItemId) => {
assert.ok(client.itemExists(id), `Could not find note with ID ${id} in client ${client.email}'s expected state.`);
const note = client.itemById(id);
assertIsNote(note);
return note;
};
const folderById = (id: ItemId) => {
assert.ok(client.itemExists(id), `Could not find folder with ID ${id} in client ${client.email}'s expected state.`);
const folder = client.itemById(id);
assertIsFolder(folder);
return folder;
@@ -244,14 +249,29 @@ const getActions = (context: FuzzContext, clientPool: ClientPool, client: Client
addAction('updateNoteBody', async ({ id }) => {
const note = noteById(id);
await client.updateNote({
...note,
body: `${note.body}\n\nUpdated.\n`,
body: `${note.body}\n\nUpdated!`,
});
return true;
}, { id: selectOrCreateWriteableNote });
addAction('attachResourceTo', async ({ noteId, resourceId }) => {
const resourceData: ResourceData = {
id: resourceId,
mimeType: 'text/plain',
title: 'Test!',
};
await client.attachResource(noteById(noteId), resourceData);
return true;
}, {
noteId: selectOrCreateWriteableNote,
resourceId: () => context.randomId(),
});
addAction('moveNote', async ({ noteId, targetFolderId }) => {
const note = noteById(noteId);
const newParent = await folderByIdOrRandom(targetFolderId, {
@@ -267,6 +287,19 @@ const getActions = (context: FuzzContext, clientPool: ClientPool, client: Client
targetFolderId: undefinedId,
});
addAction('duplicateNote', async ({ id, newNoteId }) => {
const note = noteById(id);
await client.createNote({
...note,
id: newNoteId,
});
return true;
}, {
id: selectOrCreateWriteableNote,
newNoteId: () => context.randomId(),
});
addAction('deleteNote', async ({ id }) => {
const validatedNote = noteById(id); // Ensure, e.g., that the note exists
@@ -420,8 +453,12 @@ const getActions = (context: FuzzContext, clientPool: ClientPool, client: Client
}, {
delayOnFailure: (count) => Second * count,
count: 3,
onFail: async (error) => {
logger.warn('other.sync/other.checkState failed with', error, 'retrying...');
onFail: async ({ error, willRetry }) => {
logger.warn(
'other.sync/other.checkState failed with',
error,
willRetry ? 'retrying...' : '',
);
},
});

View File

@@ -1,6 +1,8 @@
import { strict as assert } from 'assert';
import { ActionableClient, FolderData, FuzzContext, ItemId, NoteData, ShareOptions, TreeItem, assertIsFolder, isFolder } from './types';
import { ActionableClient, FolderData, FuzzContext, ItemId, NoteData, ShareOptions, TreeItem, assertIsFolder, isFolder, isNote, isResource } from './types';
import FolderRecord from './model/FolderRecord';
import { extractResourceUrls } from '@joplin/lib/urlUtils';
import ResourceRecord from './model/ResourceRecord';
interface ClientData {
childIds: ItemId[];
@@ -68,44 +70,71 @@ class ActionTracker {
}
private checkRep_() {
const checkParentId = (item: TreeItem) => {
if (item.parentId) {
const parent = this.idToItem_.get(item.parentId);
assert.ok(parent, `should find parent (id: ${item.parentId})`);
assert.ok(isFolder(parent), 'parent should be a folder');
assert.ok(parent.childIds.includes(item.id), 'parent should include the current item in its children');
}
};
const checkFolder = (folder: FolderRecord) => {
for (const childId of folder.childIds) {
checkItem(childId);
}
// Shared folders
assert.ok(folder.ownedByEmail, 'all folders should have a "shareOwner" property (even if not shared)');
if (folder.isRootSharedItem) {
assert.equal(folder.parentId, '', 'only toplevel folders should be shared');
}
for (const sharedWith of folder.shareRecipients) {
assert.ok(this.tree_.has(sharedWith), 'all sharee users should exist');
}
// isSharedWith is only valid for toplevel folders
if (folder.parentId === '') {
assert.ok(!folder.isSharedWith(folder.ownedByEmail), 'the share owner should not be in an item\'s sharedWith list');
}
// Uniqueness
assert.equal(
folder.childIds.length,
[...new Set(folder.childIds)].length,
'child IDs should be unique',
);
};
const checkNote = (note: NoteData) => {
assert.ok(!isFolder(note));
assert.ok(!isResource(note));
};
const checkResource = (resource: ResourceRecord) => {
assert.ok(!isFolder(resource));
assert.ok(!isNote(resource));
assert.ok(isResource(resource));
// References list should be up-to-date
for (const noteId of resource.referencedBy) {
const note = this.idToItem_.get(noteId);
assert.ok(note, `all references should exist (testing ID ${noteId})`);
assert.ok(isNote(note), 'all references should be notes');
assert.ok(note.body.includes(resource.id), 'all references should include the resource ID');
}
};
const checkItem = (itemId: ItemId) => {
assert.match(itemId, /^[a-zA-Z0-9]{32}$/, 'item IDs should be 32 character alphanumeric strings');
const item = this.idToItem_.get(itemId);
assert.ok(!!item, `should find item with ID ${itemId}`);
if (item.parentId) {
const parent = this.idToItem_.get(item.parentId);
assert.ok(parent, `should find parent (id: ${item.parentId})`);
assert.ok(isFolder(parent), 'parent should be a folder');
assert.ok(parent.childIds.includes(itemId), 'parent should include the current item in its children');
}
checkParentId(item);
if (isFolder(item)) {
for (const childId of item.childIds) {
checkItem(childId);
}
// Shared folders
assert.ok(item.ownedByEmail, 'all folders should have a "shareOwner" property (even if not shared)');
if (item.isRootSharedItem) {
assert.equal(item.parentId, '', 'only toplevel folders should be shared');
}
for (const sharedWith of item.shareRecipients) {
assert.ok(this.tree_.has(sharedWith), 'all sharee users should exist');
}
// isSharedWith is only valid for toplevel folders
if (item.parentId === '') {
assert.ok(!item.isSharedWith(item.ownedByEmail), 'the share owner should not be in an item\'s sharedWith list');
}
// Uniqueness
assert.equal(
item.childIds.length,
[...new Set(item.childIds)].length,
'child IDs should be unique',
);
checkFolder(item);
} else if (isNote(item)) {
checkNote(item);
} else {
checkResource(item);
}
};
@@ -263,6 +292,8 @@ class ActionTracker {
removeItemRecursive(childId);
}
} else if (isNote(item)) {
updateResourceReferences(item, { ...item, body: '' });
}
};
const mapItems = <T> (map: (item: TreeItem)=> T, startFolder?: FolderRecord) => {
@@ -282,6 +313,16 @@ class ActionTracker {
workList.push(childId);
}
}
if (isNote(item)) {
// Map linked resources
const linkedIds = extractResourceUrls(item.body);
for (const id of linkedIds) {
const item = this.idToItem_.get(id.itemId);
if (!item || !isResource(item)) continue;
result.push(map(item));
}
}
}
return result;
@@ -342,6 +383,50 @@ class ActionTracker {
this.checkRep_();
};
const updateResourceReferences = (noteBefore: NoteData|null, noteAfter: NoteData|null) => {
assert.ok(!!noteBefore || !!noteAfter, 'at least one of (noteBefore, noteAfter) must be specified');
if (noteBefore && noteAfter) {
assert.equal(noteBefore.id, noteAfter.id, 'changing note IDs is not supported');
}
const bodyBefore = noteBefore?.body ?? '';
const bodyAfter = noteAfter?.body ?? '';
if (bodyBefore === bodyAfter) return;
const id = noteBefore?.id ?? noteAfter?.id;
const referencesBefore = extractResourceUrls(bodyBefore).map(r => r.itemId);
const referencesAfter = extractResourceUrls(bodyAfter).map(r => r.itemId);
const newReferences = new Set(referencesAfter);
for (const reference of referencesBefore) {
newReferences.delete(reference);
}
const removedReferences = new Set(referencesBefore);
for (const reference of referencesAfter) {
removedReferences.delete(reference);
}
for (const reference of newReferences) {
const item = this.idToItem_.get(reference);
if (item && isResource(item)) {
updateItem(item.id, item.withReference(id), `referenced by ${id}`);
}
}
for (const reference of removedReferences) {
const item = this.idToItem_.get(reference);
if (item && isResource(item)) {
updateItem(
item.id,
item.withoutReference(id),
`dereferenced by ${id}`,
);
}
}
};
const tracker: ActionableClient = {
createNote: (data: NoteData) => {
assertWriteable(data.parentId);
@@ -350,8 +435,9 @@ class ActionTracker {
assert.ok(!this.idToItem_.has(data.id), `note ${data.id} should not yet exist`);
updateItem(data.id, {
...data,
}, 'created');
}, `created in ${data.parentId}`);
addChild(data.parentId, data.id);
updateResourceReferences(null, data);
this.checkRep_();
return Promise.resolve();
@@ -364,7 +450,7 @@ class ActionTracker {
assert.ok(!!data.parentId, `note ${data.id} should have a parentId`);
// Additional debugging information about what changed:
const changedFieldsInfo = Object.entries(data)
const changedFields = Object.entries(data)
.filter(([key, newValue]) => {
const itemKey = key as keyof NoteData;
// isShared is a virtual property
@@ -378,12 +464,39 @@ class ActionTracker {
removeChild(oldItem.parentId, data.id);
updateItem(data.id, {
...data,
}, `updated (changed fields: ${JSON.stringify(changedFieldsInfo)})`);
}, `updated (changed fields: ${JSON.stringify(changedFields)})`);
addChild(data.parentId, data.id);
updateResourceReferences(oldItem, data);
this.checkRep_();
return Promise.resolve();
},
attachResource: async (note, resource) => {
const resourceMarkup = `[resource](:/${resource.id})`;
const withAttached = { ...note, body: `${note.body}${resourceMarkup}` };
if (!tracker.itemExists(resource.id)) {
await tracker.createResource(resource);
}
await tracker.updateNote(withAttached);
return withAttached;
},
createResource: async (resource) => {
if (tracker.itemExists(resource.id)) {
// Don't double-create the item.
return Promise.resolve();
}
updateItem(
resource.id, new ResourceRecord({
...resource,
referencedBy: [],
}),
'created',
);
this.checkRep_();
return Promise.resolve();
},
createFolder: (data: FolderData) => {
const parentId = data.parentId ?? '';
assertWriteable(parentId);
@@ -395,7 +508,7 @@ class ActionTracker {
sharedWith: [],
ownedByEmail: clientId,
isShared: false,
}), 'created');
}), `created ${data.parentId ? `in ${data.parentId}` : '(toplevel)'}`);
addChild(data.parentId, data.id);
this.checkRep_();
@@ -419,7 +532,7 @@ class ActionTracker {
const item = this.idToItem_.get(id);
if (!item) throw new Error(`Not found ${id}`);
assert.ok(!isFolder(item), 'should be a note');
assert.ok(isNote(item), 'should be a note');
assertWriteable(item);
removeItemRecursive(id);
@@ -480,6 +593,7 @@ class ActionTracker {
},
moveItem: (itemId, newParentId) => {
const item = this.idToItem_.get(itemId);
assert.ok(isFolder(item) || isNote(item), `item with ${itemId} should be a folder or a note`);
const validateParameters = () => {
assert.ok(item, `item with ${itemId} should exist`);
@@ -514,10 +628,9 @@ class ActionTracker {
publishNote: (id) => {
const oldItem = this.idToItem_.get(id);
assert.ok(oldItem, 'should exist');
assert.ok(!isFolder(oldItem), 'folders cannot be published');
assert.ok(isNote(oldItem), 'only notes can be published');
assert.ok(!oldItem.published, 'should not be published');
updateItem(id, {
...oldItem,
published: true,
@@ -529,7 +642,7 @@ class ActionTracker {
unpublishNote: (id) => {
const oldItem = this.idToItem_.get(id);
assert.ok(oldItem, 'should exist');
assert.ok(!isFolder(oldItem), 'folders cannot be unpublished');
assert.ok(isNote(oldItem), 'only notes can be unpublished');
assert.ok(oldItem.published, 'should be published');
updateItem(id, {
@@ -541,9 +654,15 @@ class ActionTracker {
return Promise.resolve();
},
sync: () => Promise.resolve(),
listResources: () => {
const items = mapItems(item => {
return !isResource(item) ? null : item;
}).filter(item => !!item && item.referenceCount > 0);
return Promise.resolve(items);
},
listNotes: () => {
const notes = mapItems(item => {
return isFolder(item) ? null : item;
return !isNote(item) ? null : item;
}).filter(item => !!item).map(item => ({
...item,
isShared: isShared(item),
@@ -597,8 +716,11 @@ class ActionTracker {
return folders.length ? this.context_.randomFrom(folders) : null;
},
randomNote: async () => {
const notes = await tracker.listNotes();
randomNote: async (options) => {
let notes = await tracker.listNotes();
if (!options.includeReadOnly) {
notes = notes.filter(note => !isReadOnly(note.id));
}
const noteIndex = this.context_.randInt(0, notes.length);
return notes.length ? notes[noteIndex] : null;
},
@@ -609,6 +731,18 @@ class ActionTracker {
if (!item) throw new Error(`No item found with ID ${id}`);
return item;
},
itemExists: (id: ItemId) => {
const item = this.idToItem_.get(id);
if (!item) return false;
if (isResource(item)) return true;
const root = this.getToplevelParent_(id);
if (isFolder(root)) {
return root.ownedByEmail === client.email || root.isSharedWith(client.email);
}
return this.tree_.get(clientId).childIds.includes(id);
},
};
return tracker;
}

View File

@@ -1,5 +1,5 @@
import uuid, { createSecureRandom } from '@joplin/lib/uuid';
import { ActionableClient, FolderData, FuzzContext, HttpMethod, ItemId, Json, NoteData, RandomFolderOptions, RandomNoteOptions, ShareOptions } from './types';
import { ActionableClient, assertIsNote, FolderData, FuzzContext, HttpMethod, ItemId, Json, NoteData, RandomFolderOptions, RandomNoteOptions, ResourceData, ShareOptions } from './types';
import { join } from 'path';
import { mkdir, remove } from 'fs-extra';
import getStringProperty from './utils/getStringProperty';
@@ -14,7 +14,6 @@ import getNumberProperty from './utils/getNumberProperty';
import retryWithCount from './utils/retryWithCount';
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
import { formatMsToDateTimeLocal, msleep, Second } from '@joplin/utils/time';
import shim from '@joplin/lib/shim';
import { spawn } from 'child_process';
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
import { createInterface } from 'readline/promises';
@@ -23,6 +22,9 @@ import ProgressBar from './utils/ProgressBar';
import logDiffDebug from './utils/logDiffDebug';
import { NoteEntity } from '@joplin/lib/services/database/types';
import diffSortedStringArrays from './utils/diffSortedStringArrays';
import extractResourceIds from './utils/extractResourceIds';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
import hangingIndent from './utils/hangingIndent';
const logger = Logger.create('Client');
@@ -105,6 +107,12 @@ interface CreateRandomItemOptions extends CreateOrUpdateOptions {
quiet?: boolean;
}
class ApiResponseError extends Error {
public constructor(public readonly code: number, message: string) {
super(message);
}
}
class Client implements ActionableClient {
public readonly email: string;
@@ -122,7 +130,7 @@ class Client implements ActionableClient {
}
private static async fromAccount(account: AccountData, actionTracker: ActionTracker, context: FuzzContext) {
const id = uuid.create();
const id = context.randomId();
const profileDirectory = join(context.baseDir, id);
await mkdir(profileDirectory);
@@ -242,6 +250,14 @@ class Client implements ActionableClient {
private closed_ = false;
public async close() {
if (this.closed_) {
// This can happen if:
// - Multiple cleanup callbacks are registered for the client.
// - The client was manually closed, but also has a cleanup callback registered.
logger.info('Client', this.clientLabel_, 'already closed. Skipping.');
return;
}
assert.ok(!this.closed_, 'should not be closed');
await this.account_.onClientDisconnected();
@@ -388,22 +404,24 @@ class Client implements ActionableClient {
// eslint-disable-next-line no-dupe-class-members -- This is not a duplicate class member
private async execApiCommand_(method: 'GET', route: string): Promise<string>;
// eslint-disable-next-line no-dupe-class-members -- This is not a duplicate class member
private async execApiCommand_(method: 'POST'|'PUT', route: string, data: Json): Promise<string>;
private async execApiCommand_(method: 'POST'|'PUT', route: string, data: Json|FormData): Promise<string>;
// eslint-disable-next-line no-dupe-class-members -- This is not a duplicate class member
private async execApiCommand_(method: HttpMethod, route: string, data: Json|null = null): Promise<string> {
private async execApiCommand_(method: HttpMethod, route: string, data: Json|FormData|null = null): Promise<string> {
route = route.replace(/^[/]/, '');
const url = new URL(`http://localhost:${this.apiData_.port}/${route}`);
url.searchParams.append('token', this.apiData_.token);
this.transcript_.push(`\n[[${method} ${url}; body: ${JSON.stringify(data)}]]\n`);
const response = await shim.fetch(url.toString(), {
const response = await fetch(url.toString(), {
method,
...(data ? { body: JSON.stringify(data) } : undefined),
...(data ? {
body: data instanceof FormData ? data : JSON.stringify(data),
} : undefined),
});
if (!response.ok) {
throw new Error(`Request to ${route} failed with error: ${await response.text()}`);
throw new ApiResponseError(response.status, `Request to ${route} failed with error: ${await response.text()}`);
}
return await response.text();
@@ -468,11 +486,114 @@ class Client implements ActionableClient {
// Certain sync failures self-resolve after a background task is allowed to
// run. Delay:
delayOnFailure: retry => retry * Second * 2,
onFail: async (error) => {
onFail: async ({ error, willRetry }) => {
logger.debug('Sync error: ', error);
logger.info('Sync failed. Retrying...');
if (willRetry) {
logger.info('Sync failed. Retrying...');
}
},
});
await this.handleResourceIdChanges_();
}
// Joplin occasionally changes the ID of a resource. Handle this here.
// Assumes that the client is up-to-date with the server.
private async handleResourceIdChanges_() {
type UntrackedAttachment = {
id: ItemId;
linkedNotes: Set<ItemId>;
};
const collectUntrackedAttachments = async () => {
// Maps from untracked item IDs to the notes that contain that item.
const untrackedItemsById = new Map<ItemId, UntrackedAttachment>();
const noteActualStates = new Map<ItemId, NoteData>();
for (const note of await this.listNotes()) {
// Skip notes that are not yet in the expected state. It's possible
// that these notes still need to be synced by another client. If so,
// attachments in these notes will be processed later:
if (!this.tracker_.itemExists(note.id)) continue;
for (const itemId of extractResourceIds(note.body)) {
if (this.tracker_.itemExists(itemId)) continue;
const noteIds = untrackedItemsById.get(itemId);
if (noteIds) {
noteIds.linkedNotes.add(note.id);
} else {
untrackedItemsById.set(itemId, {
id: itemId,
linkedNotes: new Set([note.id]),
});
}
noteActualStates.set(note.id, note);
}
}
return { untrackedItemsById, noteActualStates };
};
const fetchResourceData = async (resourceId: ItemId) => {
try {
const resourceJson = JSON.parse(
await this.execApiCommand_('GET', `/resources/${resourceId}?fields=id,title,mime`),
);
const resourceData: ResourceData = {
id: getStringProperty(resourceJson, 'id'),
mimeType: getStringProperty(resourceJson, 'mime'),
title: getStringProperty(resourceJson, 'title'),
};
return resourceData;
} catch (error) {
if (error instanceof ApiResponseError && error.code === 404) {
return null;
} else {
throw error;
}
}
};
const removeResourceIds = (text: string) => {
for (const id of extractResourceIds(text)) {
text = text.split(id).join('');
}
return text;
};
const textsMatchIgnoringResources = (actual: string, expected: string) => {
return removeResourceIds(expected) === removeResourceIds(actual);
};
const { untrackedItemsById, noteActualStates } = await collectUntrackedAttachments();
for (const { id: resourceId, linkedNotes } of untrackedItemsById.values()) {
const resourceData = await fetchResourceData(resourceId);
if (!resourceData) {
logger.warn('Resource not found:', resourceId);
continue;
}
await this.createResource(resourceData);
for (const id of linkedNotes) {
const expected = this.tracker_.itemById(id);
assertIsNote(expected);
const actual = noteActualStates.get(id);
assertIsNote(actual);
if (textsMatchIgnoringResources(actual.body, expected.body)) {
const firstMatchIndex = actual.body.indexOf(resourceId);
// This relies on the fact that **all** resource IDs are length-32 strings:
const originalId = expected.body.substring(firstMatchIndex, firstMatchIndex + 32);
logger.info('Resource rewrite: Updating note', id, ': Replacing', originalId, 'with', resourceId);
await this.tracker_.updateNote({
...expected,
body: expected.body.split(originalId).join(resourceId),
});
}
}
}
}
public async createOrUpdateMany(actionCount: number) {
@@ -530,7 +651,7 @@ class Client implements ActionableClient {
const titleLength = this.context_.randInt(1, 128);
const folder = {
parentId: parentId,
id: id ?? uuid.create(),
id: id ?? this.context_.randomId(),
title: this.context_.randomString(titleLength).replace(/\n/g, ' '),
};
@@ -609,7 +730,7 @@ class Client implements ActionableClient {
parentId,
title: this.context_.randomString(titleLength),
body: this.context_.randomString(bodyLength),
id: id ?? uuid.create(),
id: id ?? this.context_.randomId(),
}, { quiet });
}
@@ -652,6 +773,52 @@ class Client implements ActionableClient {
await this.execCliCommand_('rmnote', '--permanent', '--force', id);
}
public async attachResource(note: NoteData, resource: ResourceData): Promise<NoteData> {
logger.info('Attach resource', resource.id, 'to note', note.id);
const updatedNote = await this.tracker_.attachResource(note, resource);
await this.execApiCommand_('PUT', `/notes/${encodeURIComponent(note.id)}`, {
title: updatedNote.title,
body: updatedNote.body,
parent_id: updatedNote.parentId ?? '',
});
// Create the resource on the client *after* attaching it to the note so that the
// resource is always referenced by at least one note:
await this.createResource(resource);
await this.assertNoteMatchesState_(updatedNote);
return updatedNote;
}
public async createResource(resource: ResourceData): Promise<void> {
await this.tracker_.createResource(resource);
const checkExists = async () => {
try {
await this.execApiCommand_('GET', `/resources/${resource.id}`);
return true;
} catch (error) {
if (error instanceof ApiResponseError && error.code === 404) {
return false;
}
throw error;
}
};
if (!await checkExists()) {
const resourceForm = new FormData();
resourceForm.append('data', new Blob(['test'], { type: resource.mimeType }));
resourceForm.append('props', JSON.stringify({
title: resource.title,
id: resource.id,
mime: resource.mimeType,
}));
await this.execApiCommand_('POST', '/resources', resourceForm);
}
}
public async deleteFolder(id: string) {
logger.info('Delete folder', id, 'in', this.label);
await this.tracker_.deleteFolder(id);
@@ -698,8 +865,8 @@ class Client implements ActionableClient {
}, {
count: 2,
delayOnFailure: count => count * Second,
onFail: (error)=>{
logger.warn('Share failed:', error);
onFail: ({ error, willRetry })=>{
logger.warn('Share failed:', error, willRetry ? 'Retrying...' : '');
},
});
@@ -755,6 +922,24 @@ class Client implements ActionableClient {
await this.execCliCommand_('mv', itemId, movingToRoot ? 'root' : newParentId);
}
public async listResources() {
const params = {
fields: 'id,title,mime',
include_deleted: '1',
include_conflicts: '1',
};
return await this.execPagedApiCommand_(
'GET',
'/resources',
params,
(item): ResourceData => ({
id: getStringProperty(item, 'id'),
title: getStringProperty(item, 'title'),
mimeType: getStringProperty(item, 'mime'),
}),
);
}
public async listNotes() {
const params = {
fields: 'id,parent_id,body,title,is_conflict,conflict_original_id,share_id,is_shared',
@@ -812,10 +997,14 @@ class Client implements ActionableClient {
return this.tracker_.itemById(itemId);
}
public itemExists(itemId: ItemId) {
return this.tracker_.itemExists(itemId);
}
public async checkState() {
logger.info('Check state', this.label);
type ItemSlice = { id: string };
type ItemSlice = { id: string; title: string };
const compare = (a: ItemSlice, b: ItemSlice) => {
if (a.id === b.id) return 0;
return a.id < b.id ? -1 : 1;
@@ -833,32 +1022,46 @@ class Client implements ActionableClient {
}
};
const assertSameIds = (actualSorted: ItemSlice[], expectedSorted: ItemSlice[], testLabel: string) => {
const idLogs = (ids: ItemId[], items: ItemSlice[]) => {
const itemTitle = (id: ItemId) => {
const itemTitle = items.find(item => item.id === id)?.title;
return itemTitle ? JSON.stringify(substrWithEllipsis(itemTitle, 0, 28)) : 'Unknown';
};
const output = [];
for (const id of ids) {
const log = this.globalActionTracker_.getActionLog(id);
output.push(`id: ${id} (${itemTitle(id)})`);
if (log.length > 0) {
output.push(
log
.map(item => `\t${item.source}: ${item.action}`)
.join('\n'),
);
} else {
output.push('\tNo history found');
}
}
return output.join('\n');
};
const assertSameIds = async (actualSorted: ItemSlice[], expectedSorted: ItemSlice[], assertionLabel: string) => {
const actualIds = actualSorted.map(i => i.id);
const expectedIds = expectedSorted.map(i => i.id);
const { missing, unexpected } = diffSortedStringArrays(actualIds, expectedIds);
if (missing.length || unexpected.length) {
const idLogs = (ids: string[]) => {
const output = [];
for (const id of ids) {
const log = this.globalActionTracker_.getActionLog(id);
output.push(`\nid:${id}`);
output.push(log.map(item => `\t${item.source}: ${item.action}`).join('\n'));
}
return output.join('\n');
};
throw new Error([
`IDs were different (${testLabel}):`,
missing.length && `- Expected ${JSON.stringify(missing)} to be present, but were missing.`,
unexpected.length && `- Present but should not have been: ${JSON.stringify(unexpected)}`,
'\n',
const message = [
`${assertionLabel}: IDs were different:`,
missing.length && `Expected ${JSON.stringify(missing)} to be present, but were missing.`,
unexpected.length && `Present but should not have been: ${JSON.stringify(unexpected)}`,
'Logs:',
idLogs(missing),
idLogs(unexpected),
].filter(line => !!line).join('\n'));
idLogs(missing, expectedSorted),
idLogs(unexpected, actualSorted),
].filter(line => !!line).join('\n');
throw new Error(message);
}
};
@@ -871,7 +1074,7 @@ class Client implements ActionableClient {
assertNoAdjacentEqualIds(notes, 'notes');
assertNoAdjacentEqualIds(expectedNotes, 'expectedNotes');
assertSameIds(notes, expectedNotes, 'should have the same note IDs');
await assertSameIds(notes, expectedNotes, 'Note IDs should match');
assert.deepEqual(notes, expectedNotes, 'should have the same notes as the expected state');
};
@@ -884,12 +1087,49 @@ class Client implements ActionableClient {
assertNoAdjacentEqualIds(folders, 'folders');
assertNoAdjacentEqualIds(expectedFolders, 'expectedFolders');
assertSameIds(folders, expectedFolders, 'should have the same folder IDs');
await assertSameIds(folders, expectedFolders, 'Folder IDs should match');
assert.deepEqual(folders, expectedFolders, 'should have the same folders as the expected state');
};
await checkNoteState();
await checkFolderState();
const checkResourceState = async () => {
const actualResources = [...await this.listResources()];
const actualResourceIds = new Set(actualResources.map(r => r.id));
const expectedResources = [...await this.tracker_.listResources()];
const missingResources = [];
for (const resource of expectedResources) {
if (!actualResourceIds.has(resource.id)) {
missingResources.push(resource.id);
}
}
if (missingResources.length > 0) {
const log = idLogs(missingResources, expectedResources);
throw new Error(`Missing resource(s): All expected resources should exist on the client. Resource(s) with ID(s) ${JSON.stringify(missingResources)} were not found (total resource count: ${actualResourceIds.size}).\nResource action history:\n${log}`);
}
};
const errors: Error[] = [];
const runCheck = async (check: ()=> Promise<void>) => {
try {
await check();
} catch (error) {
errors.push(error);
}
};
await runCheck(checkResourceState);
await runCheck(checkNoteState);
await runCheck(checkFolderState);
if (errors.length) {
const errorList = errors
.map((error, index) => `Error ${index + 1} of ${errors.length}: ${error}`)
.map(message => hangingIndent(message))
.join('\n');
throw new Error(`Incorrect state in client: ${this.clientLabel_}:\n${errorList}`);
}
}
}

View File

@@ -19,8 +19,8 @@ const validateId = (id: string) => {
};
export default class FolderRecord implements FolderData {
public readonly parentId: string;
public readonly id: string;
public readonly parentId: ItemId;
public readonly id: ItemId;
public readonly title: string;
public readonly ownedByEmail: string;
public readonly childIds: ItemId[];

View File

@@ -0,0 +1,49 @@
import { ItemId, ResourceData } from '../types';
interface InitializationOptions extends ResourceData {
referencedBy: ItemId[];
}
export default class ResourceRecord implements ResourceData {
public readonly parentId: undefined;
public readonly id: ItemId;
public readonly title: string;
public readonly mimeType: string;
public readonly referencedBy: readonly ItemId[] = [];
public constructor(options: InitializationOptions) {
this.id = options.id;
this.title = options.title;
this.mimeType = options.mimeType;
this.referencedBy = [...options.referencedBy];
}
public get referenceCount() {
return this.referencedBy.length;
}
public withReference(noteId: ItemId) {
if (this.referencedBy.includes(noteId)) {
return this;
}
return new ResourceRecord({
id: this.id,
title: this.title,
mimeType: this.mimeType,
referencedBy: [...this.referencedBy, noteId],
});
}
public withoutReference(noteId: ItemId) {
if (this.referencedBy.includes(noteId)) {
return this;
}
return new ResourceRecord({
id: this.id,
title: this.title,
mimeType: this.mimeType,
referencedBy: this.referencedBy.filter(ref => ref !== noteId),
});
}
}

View File

@@ -13,6 +13,7 @@ import { packagesDir } from './constants';
import ActionRunner, { ActionSpec } from './ActionRunner';
import randomString from './utils/randomString';
import { readFile } from 'fs/promises';
import randomId from './utils/randomId';
const { shimInit } = require('@joplin/lib/shim-init-node');
const globalLogger = new Logger();
@@ -55,9 +56,10 @@ interface Options {
const createContext = (options: Options, server: Server, profilesDirectory: string) => {
const random = new SeededRandom(options.seed);
// Use a separate random number generator for strings. This prevents
// Use a separate random number generator for strings and IDs. This prevents
// the random strings setting from affecting the other output.
const stringRandom = new SeededRandom(random.next());
const idRandom = new SeededRandom(random.next());
if (options.isJoplinCloud) {
logger.info('Sync target: Joplin Cloud');
@@ -71,6 +73,7 @@ const createContext = (options: Options, server: Server, profilesDirectory: stri
return (_targetLength: number) => `Placeholder (x${stringCount++})`;
}
})();
const randomIdGenerator = randomId((min, max) => idRandom.nextInRange(min, max));
const fuzzContext: FuzzContext = {
serverUrl: server.url,
@@ -82,6 +85,7 @@ const createContext = (options: Options, server: Server, profilesDirectory: stri
randInt: (a, b) => random.nextInRange(a, b),
randomFrom: (data) => data[random.nextInRange(0, data.length)],
randomString: randomStringGenerator,
randomId: randomIdGenerator,
keepAccounts: options.keepAccountsOnClose,
};
return fuzzContext;

View File

@@ -1,5 +1,6 @@
import type Client from './Client';
import type FolderRecord from './model/FolderRecord';
import ResourceRecord from './model/ResourceRecord';
export type Json = string|number|Json[]|{ [key: string]: Json };
@@ -25,12 +26,26 @@ export interface DetailedFolderData extends FolderData {
isShared: boolean;
}
export type TreeItem = NoteData|FolderRecord;
export interface ResourceData {
id: ItemId;
title: string;
mimeType: string;
}
export type TreeItem = NoteData|FolderRecord|ResourceRecord;
export const isFolder = (item: TreeItem): item is FolderRecord => {
return 'childIds' in item;
};
export const isResource = (item: TreeItem): item is ResourceRecord => {
return 'mimeType' in item;
};
export const isNote = (item: TreeItem): item is NoteData => {
return !isFolder(item) && !isResource(item);
};
// Typescript type assertions require type definitions on the left for arrow functions.
// See https://github.com/microsoft/TypeScript/issues/53450.
export const assertIsFolder: (item: TreeItem)=> asserts item is FolderRecord = item => {
@@ -57,6 +72,7 @@ export interface FuzzContext {
execApi: (method: HttpMethod, route: string, debugAction: Json)=> Promise<Json>;
randInt: (low: number, high: number)=> number;
randomString: (targetLength: number)=> string;
randomId: ()=> string;
randomFrom: <T> (data: T[])=> T;
}
@@ -82,6 +98,8 @@ export interface ActionableClient {
deleteNote(id: ItemId): Promise<void>;
createNote(data: NoteData): Promise<void>;
updateNote(data: NoteData): Promise<void>;
attachResource(note: NoteData, resource: ResourceData): Promise<NoteData>;
createResource(resource: ResourceData): Promise<void>;
moveItem(itemId: ItemId, newParentId: ItemId): Promise<void>;
publishNote(id: ItemId): Promise<void>;
unpublishNote(id: ItemId): Promise<void>;
@@ -89,10 +107,12 @@ export interface ActionableClient {
listNotes(): Promise<NoteData[]>;
listFolders(): Promise<DetailedFolderData[]>;
listResources(): Promise<ResourceData[]>;
allFolderDescendants(parentId: ItemId): Promise<ItemId[]>;
randomFolder(options: RandomFolderOptions): Promise<FolderRecord>;
randomNote(options: RandomNoteOptions): Promise<NoteData>;
itemById(id: ItemId): TreeItem;
itemExists(id: ItemId): boolean;
}
export interface UserData {

View File

@@ -0,0 +1,7 @@
import { extractResourceUrls } from '@joplin/lib/urlUtils';
const extractResourceIds = (text: string) => {
return extractResourceUrls(text).map(item => item.itemId);
};
export default extractResourceIds;

View File

@@ -5,7 +5,9 @@ const getProperty = (object: unknown, propertyName: string) => {
}
if (!(propertyName in object)) {
throw new Error(`No such property ${JSON.stringify(propertyName)} in object`);
throw new Error(
`No such property ${JSON.stringify(propertyName)} in object. Available keys: (${JSON.stringify(Object.keys(object))})`,
);
}
return object[propertyName as keyof object];

View File

@@ -0,0 +1,7 @@
// Hanging indent: Indents all lines after the first
const hangingIndent = (text: string, indentation = ' ') => {
return text.replace(/\n/g, `\n${indentation}`);
};
export default hangingIndent;

View File

@@ -0,0 +1,12 @@
import randomId from './randomId';
describe('randomId', () => {
test('should generate a 32-character alphanumeric ID', () => {
expect(
randomId((_low, high) => high - 1)(),
).toBe('ffffffffffffffffffffffffffffffff');
expect(
randomId((low, _high) => low)(),
).toBe('00000000000000000000000000000000');
});
});

View File

@@ -0,0 +1,16 @@
type OnNextRandom = (lowInclusive: number, highExclusive: number)=> number;
const randomId = (nextRandomInteger: OnNextRandom)=> () => {
const bytes = [];
for (let i = 0; i < 16; i++) {
bytes.push(nextRandomInteger(0, 256));
}
return Buffer.from(bytes)
.toString('hex')
.toLowerCase()
.padStart(32, '0');
};
export default randomId;

View File

@@ -3,10 +3,15 @@ import { msleep } from '@joplin/utils/time';
const logger = Logger.create('retryWithCount');
interface FailureEvent {
error: Error;
willRetry: boolean;
}
interface Options {
count: number;
delayOnFailure?: (retryCount: number)=> number;
onFail: (error: Error)=> void|Promise<void>;
onFail: (event: FailureEvent)=> void|Promise<void>;
}
const retryWithCount = async (task: ()=> Promise<void>, { count, delayOnFailure, onFail }: Options) => {
@@ -15,10 +20,11 @@ const retryWithCount = async (task: ()=> Promise<void>, { count, delayOnFailure,
try {
return await task();
} catch (error) {
await onFail(error);
lastError = error;
const willRetry = retry + 1 < count;
await onFail({ error, willRetry });
const delay = willRetry && delayOnFailure ? delayOnFailure(retry + 1) : 0;
if (delay) {
logger.info(`Retrying after ${delay}ms...`);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -960,7 +960,7 @@ msgstr "Det går inte att hitta \"%s\"."
#: packages/app-cli/app/command-mkbook.ts:28
msgid "Cannot find: \"%s\""
msgstr "Det går inte att hitta: \"%s\"."
msgstr "Det går inte att hitta: \"%s\""
#: packages/app-cli/app/command-sync.ts:203
msgid "Cannot initialise synchroniser."
@@ -2357,7 +2357,7 @@ msgstr "Aktivera förkortningssyntax"
#: packages/lib/models/settings/builtInMetadata.ts:1093
msgid "Enable ABC musical notation support"
msgstr "Aktivera stöd för ABC musiknotation."
msgstr "Aktivera stöd för ABC musiknotation"
#: packages/lib/models/settings/builtInMetadata.ts:1095
msgid "Enable audio player"

View File

@@ -7,6 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Türkçe\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Last-Translator: Arda Kılıçdağı <arda@kilicdagi.com>\n"
"Language-Team: Turkish (Turkey)\n"
"Language: tr_TR\n"
@@ -328,7 +330,7 @@ msgstr "A5"
#: packages/lib/models/settings/builtInMetadata.ts:1121
msgid "ABC musical notation: Options"
msgstr ""
msgstr "ABC müzik notasyonu: Seçenekler"
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.tsx:62
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.tsx:240
@@ -424,7 +426,7 @@ msgstr "Gövde Ekle"
#: packages/editor/ProseMirror/plugins/tablePlugin.ts:31
msgid "Add column"
msgstr ""
msgstr "Sütun ekle"
#: packages/app-mobile/components/buttons/FloatingActionButton.tsx:66
#: packages/app-mobile/components/ComboBox.tsx:103
@@ -441,9 +443,8 @@ msgid "Add recipient:"
msgstr "Alıcı ekle:"
#: packages/editor/ProseMirror/plugins/tablePlugin.ts:26
#, fuzzy
msgid "Add row"
msgstr "Yeni bir şey ekle"
msgstr "Satır ekle"
#: packages/app-mobile/components/TagEditor.tsx:281
msgid "Add tags:"
@@ -801,7 +802,7 @@ msgstr "Beta"
#: packages/app-mobile/components/NoteEditor/commandDeclarations.ts:123
msgid "Block code"
msgstr ""
msgstr "Blok kod"
#: packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts:55
#: packages/app-mobile/components/NoteEditor/commandDeclarations.ts:80
@@ -919,9 +920,8 @@ msgid "Cannot change encrypted item"
msgstr "Şifrelenmiş öğe değiştirilemiyor"
#: packages/lib/commands/convertNoteToMarkdown.ts:42
#, fuzzy
msgid "Cannot convert read-only item: \"%s\""
msgstr "Yeni bir not oluşturulamadı: %s"
msgstr "Salt okunur öğe dönüştürülemiyor: %s"
#: packages/lib/models/Note.ts:622
msgid "Cannot copy note to \"%s\" notebook"
@@ -1077,7 +1077,7 @@ msgstr "Kontrol ediliyor... Lütfen bekleyin."
#: packages/app-desktop/gui/NoteContentPropertiesDialog.tsx:114
msgid "Chinese/Japanese/Korean characters"
msgstr ""
msgstr "Çince/Japonca/Korece karakterler"
#: packages/app-mobile/components/screens/Note/commands/attachFile.ts:98
msgid "Choose an option"
@@ -1244,7 +1244,7 @@ msgstr "Komut"
#: packages/app-cli/app/command-keymap.ts:30
msgid "COMMAND"
msgstr ""
msgstr "KOMUT"
#: packages/app-desktop/plugins/GotoAnything.tsx:783
msgid "Command palette"
@@ -1309,9 +1309,8 @@ msgid "Configuration"
msgstr "Yapılandırma"
#: packages/app-cli/app/command-keymap.ts:24
#, fuzzy
msgid "Configured keyboard shortcuts:"
msgstr "Klavye Kısayolları"
msgstr "Ayarlanmış klavye Kısayolları:"
#: packages/lib/models/settings/builtInMetadata.ts:1296
msgid "Configures the size of scrollbars used in the app."
@@ -1386,9 +1385,8 @@ msgid "Convert it"
msgstr "Dönüştür"
#: packages/lib/commands/convertNoteToMarkdown.ts:18
#, fuzzy
msgid "Convert to Markdown"
msgstr "Notu Markdown'a dönüştür"
msgstr "Markdown'a dönüştür"
#: packages/app-mobile/components/screens/Note/Note.tsx:1350
msgid "Convert to note"
@@ -1494,9 +1492,8 @@ msgid "Could not connect to plugin repository."
msgstr "Eklenti sunucusuna bağlanılamadı."
#: packages/lib/commands/convertNoteToMarkdown.ts:70
#, fuzzy
msgid "Could not convert notes to Markdown: %s"
msgstr "Not Markdown'a dönüştürülemedi: %s"
msgstr "Notlar Markdown'a dönüştürülemedi: %s"
#: packages/app-desktop/InteropServiceHelper.ts:235
msgid "Could not export notes: %s"
@@ -1767,9 +1764,8 @@ msgid "Delete attachment \"%s\"?"
msgstr "\"%s\" eki silinsin mi?"
#: packages/editor/ProseMirror/plugins/tablePlugin.ts:41
#, fuzzy
msgid "Delete column"
msgstr "Satırı sil"
msgstr "Sütunu sil"
#: packages/server/src/services/TaskService.ts:27
msgid "Delete expired sessions"
@@ -1810,19 +1806,19 @@ msgid "Delete profile \"%s\""
msgstr "\"%s\" profilini sil"
#: packages/app-desktop/gui/ProfileEditor.tsx:147
#, fuzzy
msgid ""
"Delete profile \"%s\"?\n"
"\n"
"All data, including notes, notebooks and tags will be permanently deleted."
msgstr ""
"Not defterleri, etiketler ve notlar da dahil olmak üzere tüm veriler kalıcı "
"olarak silinecek."
"“%s” profili silinsin mi?\n"
"\n"
"Notlar, not defterleri ve etiketler dâhil tüm veriler kalıcı olarak "
"silinecektir."
#: packages/editor/ProseMirror/plugins/tablePlugin.ts:36
#, fuzzy
msgid "Delete row"
msgstr "Notu sil"
msgstr "Satırı sil"
#: packages/app-mobile/components/ScreenHeader/index.tsx:487
msgid "Delete selected notes"
@@ -1835,6 +1831,8 @@ msgid ""
"All notes associated with this tag will remain, but the tag will be removed "
"from all notes."
msgstr ""
"\"%s\" etiketini silmek ister misiniz? Bu etiketle ilişkili tüm notlar "
"kalacak, ancak etiket tüm notlardan kaldırılacaktır."
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.ts:38
#: packages/app-mobile/components/side-menu-content.tsx:414
@@ -2017,7 +2015,7 @@ msgstr "Notla ilgili tüm bilgileri görüntüler."
#: packages/app-cli/app/command-keymap.ts:14
msgid "Displays the configured keyboard shortcuts."
msgstr ""
msgstr "Yapılandırılmış klavye kısayollarını görüntüler."
#: packages/app-cli/app/command-cat.ts:14
msgid "Displays the given note."
@@ -2237,9 +2235,8 @@ msgid "Edit profile configuration..."
msgstr "Profil ayarlarını düzenle…"
#: packages/app-mobile/components/screens/tags.tsx:64
#, fuzzy
msgid "Edit tag"
msgstr "Notu düzenle."
msgstr "Etiketi düzenle"
#: packages/app-desktop/gui/MainScreen.tsx:129
#: packages/app-desktop/gui/NoteContentPropertiesDialog.tsx:151
@@ -2349,9 +2346,8 @@ msgid "Enable abbreviation syntax"
msgstr "Kısaltma söz dizimini etkinleştir"
#: packages/lib/models/settings/builtInMetadata.ts:1093
#, fuzzy
msgid "Enable ABC musical notation support"
msgstr "Fountain söz dizimi desteğini etkinleştir"
msgstr "ABC müzik notasyon desteğini etkinleştir"
#: packages/lib/models/settings/builtInMetadata.ts:1095
msgid "Enable audio player"
@@ -3133,9 +3129,8 @@ msgid "Import or export your data"
msgstr "Verini içeri veya dışarı aktar"
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.ts:20
#, fuzzy
msgid "Import..."
msgstr "Dışa aktarılıyor..."
msgstr "İçe aktar"
#: packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteImportButton.tsx:76
msgid "Imported successfully!"
@@ -3515,7 +3510,7 @@ msgstr "Not tarihçesini şu kadar süre tut"
#: packages/lib/models/settings/builtInMetadata.ts:2037
msgid "Keep notes in the trash for"
msgstr "Çöp kutusunda notları şu kadar süre tut: "
msgstr "Çöp kutusunda notları şu kadar süre tut:"
#: packages/lib/models/settings/builtInMetadata.ts:1470
msgid "Keyboard Mode"
@@ -3535,7 +3530,7 @@ msgstr "Keychain Desteği: %s"
#: packages/app-cli/app/command-keymap.ts:30
msgid "KEYS"
msgstr ""
msgstr "TUŞLAR"
#: packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.tsx:74
msgid "Keys that need upgrading"
@@ -3763,6 +3758,8 @@ msgid ""
"Manage your profiles. You can rename or delete profiles. The active profile "
"cannot be deleted."
msgstr ""
"Profillerini yönet. Profilleri yeniden adlandırabilir veya silebilirsin. O "
"an kullanılan aktif profil silinemez."
#. `generate-ppk`
#: packages/app-cli/app/command-e2ee.ts:19
@@ -3923,7 +3920,6 @@ msgstr[0] "%d not \"%s\" not defterine taşınsın mı?"
msgstr[1] "%d not \"%s\" not defterine taşınsın mı?"
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.ts:34
#, fuzzy
msgid ""
"Move %d notebooks to the trash?\n"
"\n"
@@ -4174,7 +4170,7 @@ msgstr "Güncelleme bulunamadı"
#: packages/lib/components/shared/SamlShared.ts:12
msgid "No URL for SAML authentication set."
msgstr ""
msgstr "URL veya SAML doğrulaması ayarlanmamış."
#: packages/app-cli/app/command-share.ts:188
#: packages/app-cli/app/command-share.ts:208
@@ -4516,7 +4512,7 @@ msgstr "Senkronizasyon Sihirbazını Aç…"
#: packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx:635
msgid "Open-source licences"
msgstr ""
msgstr "Açık kaynaklı lisanslar"
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.ts:87
msgid "Open..."
@@ -4555,6 +4551,8 @@ msgid ""
"Options that should be used whenever rendering ABC code. It must be a JSON5 "
"object. The full list of options is available at: %s"
msgstr ""
"ABC kodu her işlendiğinde kullanılacak seçenekler. Bir JSON5 nesnesi "
"olmalıdır. Seçeneklerin tam listesi şu adreste mevcuttur: %s"
#: packages/app-mobile/components/NoteEditor/commandDeclarations.ts:103
msgid "Ordered list"
@@ -4920,9 +4918,8 @@ msgid "Profile name"
msgstr "Profil adı"
#: packages/app-desktop/gui/ProfileEditor.tsx:120
#, fuzzy
msgid "Profile name cannot be empty"
msgstr "Şifre boş olamaz"
msgstr "Profil adı boş olamaz"
#: packages/app-desktop/gui/ProfileEditor.tsx:116
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.ts:18
@@ -5115,11 +5112,8 @@ msgid "Remove"
msgstr "Sil"
#: packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx:134
#, fuzzy
msgid "Remove %d tags from all notes? This cannot be undone."
msgstr ""
"Model silinip yeniden indirilsin mi?\n"
"Bu işlem geri alınamaz."
msgstr "Tüm notlardan %d etiket kaldırılsın mı? Bu işlem geri alınamaz."
#: packages/app-mobile/components/TagEditor.tsx:136
msgid "Remove %s"
@@ -5536,7 +5530,7 @@ msgstr "Ana not defterini seç"
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.ts:42
msgid "Select the type of file to be imported:"
msgstr ""
msgstr "İçe aktarılacak dosya türünü seçin:"
#: packages/app-mobile/components/ComboBox.tsx:378
msgid "Selected: %s"
@@ -5861,7 +5855,7 @@ msgstr "Kaynak: "
#: packages/app-cli/app/command-keymap.ts:35
msgid "SPACE"
msgstr ""
msgstr "ALAN"
#: packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx:456
msgid "Spacer"
@@ -6172,18 +6166,16 @@ msgid "Tab moves focus"
msgstr "Tab odak hareket ettirir"
#: packages/app-mobile/components/NoteEditor/commandDeclarations.ts:118
#, fuzzy
msgid "Table"
msgstr "Etkinleştir"
msgstr "Tablo"
#: packages/lib/models/settings/builtInMetadata.ts:1440
msgid "Tabloid"
msgstr "Tablo"
#: packages/app-mobile/components/screens/tags.tsx:206
#, fuzzy
msgid "Tag: %s"
msgstr "Kullanım: %s"
msgstr "Etiket: %s"
#: packages/app-cli/app/command-import.ts:58
#: packages/app-desktop/gui/ImportScreen.tsx:94
@@ -6404,7 +6396,6 @@ msgid "The note \"%s\" has been successfully restored to the notebook \"%s\"."
msgstr "“%s” notu başarılı bir şekilde “%s” not defterine geri yüklendi."
#: packages/lib/commands/convertNoteToMarkdown.ts:64
#, fuzzy
msgid ""
"The note has been converted to Markdown and the original note has been moved "
"to the trash"
@@ -6412,7 +6403,8 @@ msgid_plural ""
"The notes have been converted to Markdown and the original notes have been "
"moved to the trash"
msgstr[0] "Not Markdown'a dönüştürüldü ve orijinal not çöp kutusuna taşındı"
msgstr[1] "Not Markdown'a dönüştürüldü ve orijinal not çöp kutusuna taşındı"
msgstr[1] ""
"Notlar Markdown'a dönüştürüldü ve orijinal notlar çöp kutusuna taşındı"
#: packages/app-desktop/gui/TrashNotification/TrashNotification.tsx:45
msgid "The note was successfully moved to the trash."
@@ -6991,7 +6983,7 @@ msgstr "Şimdi dene"
#: packages/app-cli/app/command-keymap.ts:30
msgid "TYPE"
msgstr ""
msgstr "TİP"
#: packages/app-cli/app/command-help.ts:72
msgid ""

View File

@@ -25,7 +25,7 @@
"@joplin/renderer": "~3.6",
"@joplin/utils": "~3.6",
"compare-versions": "6.1.1",
"dayjs": "1.11.18",
"dayjs": "1.11.19",
"execa": "4.1.0",
"fs-extra": "11.3.2",
"gettext-parser": "7.0.1",
@@ -54,14 +54,14 @@
"@types/mustache": "4.2.6",
"@types/node": "18.19.130",
"@types/node-fetch": "2.6.13",
"@types/yargs": "17.0.33",
"gettext-extractor": "3.8.0",
"@types/yargs": "17.0.34",
"gettext-extractor": "4.0.1",
"gulp": "4.0.2",
"html-entities": "1.4.0",
"jest": "29.7.0",
"js-yaml": "4.1.1",
"rss": "1.2.2",
"sass": "1.93.2",
"sass": "1.93.3",
"sqlite3": "5.1.6",
"style-to-js": "1.1.18",
"ts-node": "10.9.2",

View File

@@ -200,6 +200,7 @@
"v3.5.11": true,
"v3.5.12": true,
"v3.6.1": true,
"v3.6.2": true
"v3.6.2": true,
"android-v3.5.9": true
}
}

View File

@@ -186,6 +186,12 @@ async function createRelease(projectName: string, releaseConfig: ReleaseConfig,
}
const uploadToGitHubRelease = async (projectName: string, tagName: string, isPreRelease: boolean, releaseFiles: Record<string, Release>) => {
const allPublishDisabled = Object.values(releaseFiles).every(r => !r.publish);
if (allPublishDisabled) {
console.info('All release files have publishing disabled - skipping GitHub release creation');
return;
}
console.info(`Creating GitHub release ${tagName}...`);
const releaseOptions = { isPreRelease: isPreRelease };
@@ -323,7 +329,7 @@ async function main() {
await uploadToGitHubRelease(mainProjectName, tagName, isPreRelease, releaseFiles);
console.info(`Main download URL: ${releaseFiles['main'].downloadUrl}`);
if (releaseFiles['main']) console.info(`Main download URL: ${releaseFiles['main'].downloadUrl}`);
const changelogPath = `${rootDir}/readme/about/changelog/android.md`;

View File

@@ -11,7 +11,7 @@
"browserify": "14.5.0",
"rollup": "0.50.1",
"standard": "17.1.2",
"turndown": "7.2.1",
"turndown": "7.2.2",
"turndown-attendant": "0.0.3"
},
"files": [

View File

@@ -14,9 +14,9 @@
"jsdom": "26.1.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "28.0.8",
"@rollup/plugin-commonjs": "28.0.9",
"@rollup/plugin-node-resolve": "16.0.3",
"@rollup/plugin-replace": "6.0.2",
"@rollup/plugin-replace": "6.0.3",
"browserify": "14.5.0",
"rollup": "4.2.0",
"standard": "17.1.2",

View File

@@ -37,7 +37,7 @@
"dependencies": {
"@joplin/fork-htmlparser2": "^4.1.60",
"async-mutex": "0.5.0",
"dayjs": "1.11.18",
"dayjs": "1.11.19",
"execa": "5.1.1",
"fs-extra": "11.3.2",
"glob": "11.0.3",

View File

@@ -8,7 +8,7 @@ Joplin Server Business is a synchronisation server that you can install on your
Your teams can collaborate on notebooks and share information. They can also publish notes to the internet or within your own intranet. All that secured by Joplin end-to-end encryption.
Interested? [Contact us for a quote](mailto:jsb-inquiry@joplin.cloud?subject=Joplin%20Server%20Business%20inquiry)
Interested? [Contact us for a quote](https://tally.so/r/D4BlOE)
</div>
@@ -88,7 +88,7 @@ Keep all your resources in one place. Save and share images, PDFs, videos, audio
To find out more about Joplin Server Business and how it can be integrated to your organisation, feel free to contact us. Our experts can prepare a demo for you. We can provide a quote to accommodate your company’s needs.
[Contact us for a quote!](mailto:jsb-inquiry@joplin.cloud?subject=Joplin%20Server%20Business%20inquiry)
[Contact us for a quote!](https://tally.so/r/D4BlOE)
## Difference with Joplin Server

698
yarn.lock

File diff suppressed because it is too large Load Diff