You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-02-01 07:49:31 +02:00
Compare commits
46 Commits
notarize_p
...
renovate/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b5288e487 | ||
|
|
7651d8e3c4 | ||
|
|
d5c72c13cb | ||
|
|
4377634e7b | ||
|
|
69ec5c7f86 | ||
|
|
f02b0f48d8 | ||
|
|
4d77c1385f | ||
|
|
c83f9ddeac | ||
|
|
1b9c11df7b | ||
|
|
333a8723e8 | ||
|
|
e030c8271d | ||
|
|
560bc31445 | ||
|
|
c71aeb74b2 | ||
|
|
ffaf2acb66 | ||
|
|
f442f1fb23 | ||
|
|
81a1451820 | ||
|
|
b3a3d71461 | ||
|
|
1db38c3232 | ||
|
|
42e645eb70 | ||
|
|
3860f44d06 | ||
|
|
4df0f8668d | ||
|
|
306d0fddd8 | ||
|
|
56d12b28f2 | ||
|
|
6c5ea4872a | ||
|
|
9856e8ae93 | ||
|
|
5712da4c0f | ||
|
|
4f7ee56444 | ||
|
|
8e2b6ca296 | ||
|
|
0172bb0ad8 | ||
|
|
1d38e443ba | ||
|
|
5ad19b7261 | ||
|
|
70293478a2 | ||
|
|
3aaa20254f | ||
|
|
42c248f7ca | ||
|
|
ac1e94a8df | ||
|
|
daff4496cf | ||
|
|
1e00078228 | ||
|
|
03a1de9370 | ||
|
|
55ef256c65 | ||
|
|
6d115db16f | ||
|
|
5853031fde | ||
|
|
47db2ae962 | ||
|
|
b960a2a8b0 | ||
|
|
fcaa7d2a98 | ||
|
|
99284ae135 | ||
|
|
66ae58c81b |
@@ -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
5
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
90
packages/app-desktop/tools/notarizeFile.js
Normal file
90
packages/app-desktop/tools/notarizeFile.js
Normal 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
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 --;
|
||||
}
|
||||
|
||||
|
||||
47
packages/editor/CodeMirror/theme.ts
vendored
47
packages/editor/CodeMirror/theme.ts
vendored
@@ -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 [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('', [
|
||||
{
|
||||
|
||||
@@ -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[]> = {};
|
||||
|
||||
@@ -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) => ``,
|
||||
},
|
||||
{
|
||||
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();
|
||||
|
||||
|
||||
@@ -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', []],
|
||||
['', []],
|
||||
];
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...' : '',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
49
packages/tools/fuzzer/model/ResourceRecord.ts
Normal file
49
packages/tools/fuzzer/model/ResourceRecord.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
7
packages/tools/fuzzer/utils/extractResourceIds.ts
Normal file
7
packages/tools/fuzzer/utils/extractResourceIds.ts
Normal 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;
|
||||
@@ -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];
|
||||
|
||||
7
packages/tools/fuzzer/utils/hangingIndent.ts
Normal file
7
packages/tools/fuzzer/utils/hangingIndent.ts
Normal 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;
|
||||
12
packages/tools/fuzzer/utils/randomId.test.ts
Normal file
12
packages/tools/fuzzer/utils/randomId.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
16
packages/tools/fuzzer/utils/randomId.ts
Normal file
16
packages/tools/fuzzer/utils/randomId.ts
Normal 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;
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user