You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-01-05 00:12:33 +02:00
Compare commits
2 Commits
v2.1.3
...
sync_batch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81427a1f2 | ||
|
|
958e9163b6 |
@@ -872,9 +872,6 @@ packages/lib/eventManager.js.map
|
||||
packages/lib/file-api-driver-joplinServer.d.ts
|
||||
packages/lib/file-api-driver-joplinServer.js
|
||||
packages/lib/file-api-driver-joplinServer.js.map
|
||||
packages/lib/file-api-driver-memory.d.ts
|
||||
packages/lib/file-api-driver-memory.js
|
||||
packages/lib/file-api-driver-memory.js.map
|
||||
packages/lib/file-api-driver.test.d.ts
|
||||
packages/lib/file-api-driver.test.js
|
||||
packages/lib/file-api-driver.test.js.map
|
||||
@@ -1394,12 +1391,6 @@ packages/lib/services/spellChecker/SpellCheckerService.js.map
|
||||
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.d.ts
|
||||
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
|
||||
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js.map
|
||||
packages/lib/services/synchronizer/ItemUploader.d.ts
|
||||
packages/lib/services/synchronizer/ItemUploader.js
|
||||
packages/lib/services/synchronizer/ItemUploader.js.map
|
||||
packages/lib/services/synchronizer/ItemUploader.test.d.ts
|
||||
packages/lib/services/synchronizer/ItemUploader.test.js
|
||||
packages/lib/services/synchronizer/ItemUploader.test.js.map
|
||||
packages/lib/services/synchronizer/LockHandler.d.ts
|
||||
packages/lib/services/synchronizer/LockHandler.js
|
||||
packages/lib/services/synchronizer/LockHandler.js.map
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -858,9 +858,6 @@ packages/lib/eventManager.js.map
|
||||
packages/lib/file-api-driver-joplinServer.d.ts
|
||||
packages/lib/file-api-driver-joplinServer.js
|
||||
packages/lib/file-api-driver-joplinServer.js.map
|
||||
packages/lib/file-api-driver-memory.d.ts
|
||||
packages/lib/file-api-driver-memory.js
|
||||
packages/lib/file-api-driver-memory.js.map
|
||||
packages/lib/file-api-driver.test.d.ts
|
||||
packages/lib/file-api-driver.test.js
|
||||
packages/lib/file-api-driver.test.js.map
|
||||
@@ -1380,12 +1377,6 @@ packages/lib/services/spellChecker/SpellCheckerService.js.map
|
||||
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.d.ts
|
||||
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
|
||||
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js.map
|
||||
packages/lib/services/synchronizer/ItemUploader.d.ts
|
||||
packages/lib/services/synchronizer/ItemUploader.js
|
||||
packages/lib/services/synchronizer/ItemUploader.js.map
|
||||
packages/lib/services/synchronizer/ItemUploader.test.d.ts
|
||||
packages/lib/services/synchronizer/ItemUploader.test.js
|
||||
packages/lib/services/synchronizer/ItemUploader.test.js.map
|
||||
packages/lib/services/synchronizer/LockHandler.d.ts
|
||||
packages/lib/services/synchronizer/LockHandler.js
|
||||
packages/lib/services/synchronizer/LockHandler.js.map
|
||||
|
||||
@@ -33,7 +33,6 @@ class Command extends BaseCommand {
|
||||
return [
|
||||
['--target <target>', _('Sync to provided target (defaults to sync.target config value)')],
|
||||
['--upgrade', _('Upgrade the sync target to the latest version.')],
|
||||
['--use-lock <value>', 'Disable local locks that prevent multiple clients from synchronizing at the same time (Default = 1)'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -125,21 +124,17 @@ class Command extends BaseCommand {
|
||||
const lockFilePath = `${require('os').tmpdir()}/synclock_${md5(escape(Setting.value('profileDir')))}`; // https://github.com/pvorb/node-md5/issues/41
|
||||
if (!(await fs.pathExists(lockFilePath))) await fs.writeFile(lockFilePath, 'synclock');
|
||||
|
||||
const useLock = args.options.useLock !== 0;
|
||||
try {
|
||||
if (await Command.isLocked(lockFilePath)) throw new Error(_('Synchronisation is already in progress.'));
|
||||
|
||||
if (useLock) {
|
||||
try {
|
||||
if (await Command.isLocked(lockFilePath)) throw new Error(_('Synchronisation is already in progress.'));
|
||||
|
||||
this.releaseLockFn_ = await Command.lockFile(lockFilePath);
|
||||
} catch (error) {
|
||||
if (error.code == 'ELOCKED') {
|
||||
const msg = _('Lock file is already being hold. If you know that no synchronisation is taking place, you may delete the lock file at "%s" and resume the operation.', error.file);
|
||||
this.stdout(msg);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
this.releaseLockFn_ = await Command.lockFile(lockFilePath);
|
||||
} catch (error) {
|
||||
if (error.code == 'ELOCKED') {
|
||||
const msg = _('Lock file is already being hold. If you know that no synchronisation is taking place, you may delete the lock file at "%s" and resume the operation.', error.file);
|
||||
this.stdout(msg);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const cleanUp = () => {
|
||||
|
||||
@@ -8,46 +8,25 @@ joplin.plugins.register({
|
||||
iconName: 'fas fa-music',
|
||||
});
|
||||
|
||||
await joplin.settings.registerSettings({
|
||||
'myCustomSetting': {
|
||||
value: 123,
|
||||
type: SettingItemType.Int,
|
||||
section: 'myCustomSection',
|
||||
public: true,
|
||||
label: 'My Custom Setting',
|
||||
},
|
||||
await joplin.settings.registerSetting('myCustomSetting', {
|
||||
value: 123,
|
||||
type: SettingItemType.Int,
|
||||
section: 'myCustomSection',
|
||||
public: true,
|
||||
label: 'My Custom Setting',
|
||||
});
|
||||
|
||||
'multiOptionTest': {
|
||||
value: 'en',
|
||||
type: SettingItemType.String,
|
||||
section: 'myCustomSection',
|
||||
isEnum: true,
|
||||
public: true,
|
||||
label: 'Multi-options test',
|
||||
options: {
|
||||
'en': 'English',
|
||||
'fr': 'French',
|
||||
'es': 'Spanish',
|
||||
},
|
||||
},
|
||||
|
||||
'mySecureSetting': {
|
||||
value: 'hunter2',
|
||||
type: SettingItemType.String,
|
||||
section: 'myCustomSection',
|
||||
public: true,
|
||||
secure: true,
|
||||
label: 'My Secure Setting',
|
||||
},
|
||||
|
||||
'myFileSetting': {
|
||||
value: 'abcd',
|
||||
type: SettingItemType.String,
|
||||
section: 'myCustomSection',
|
||||
public: true,
|
||||
label: 'My file setting',
|
||||
description: 'This setting will be saved to settings.json',
|
||||
['storage' as any]: 2, // Should be `storage: SettingStorage.File`
|
||||
await joplin.settings.registerSetting('multiOptionTest', {
|
||||
value: 'en',
|
||||
type: SettingItemType.String,
|
||||
section: 'myCustomSection',
|
||||
isEnum: true,
|
||||
public: true,
|
||||
label: 'Multi-options test',
|
||||
options: {
|
||||
'en': 'English',
|
||||
'fr': 'French',
|
||||
'es': 'Spanish',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,11 +47,7 @@ joplin.plugins.register({
|
||||
iconName: 'fas fa-drum',
|
||||
execute: async () => {
|
||||
const value = await joplin.settings.value('myCustomSetting');
|
||||
console.info('Current value is: ' + value);
|
||||
const secureValue = await joplin.settings.value('mySecureSetting');
|
||||
console.info('Secure value is: ' + secureValue);
|
||||
const fileValue = await joplin.settings.value('myFileSetting');
|
||||
console.info('Setting in file is: ' + fileValue);
|
||||
alert('Current value is: ' + value);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,9 +5,6 @@ set -e
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
ROOT_DIR="$SCRIPT_DIR/../../../../.."
|
||||
|
||||
API_BASE_URL="https://test.joplincloud.com"
|
||||
# API_BASE_URL="http://api.joplincloud.local:22300"
|
||||
|
||||
COMMANDS=($(echo $1 | tr "," "\n"))
|
||||
PROFILE_DIR=~/.config/joplindev-testperf
|
||||
|
||||
@@ -19,7 +16,7 @@ for CMD in "${COMMANDS[@]}"
|
||||
do
|
||||
if [[ $CMD == "createUsers" ]]; then
|
||||
|
||||
curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' $API_BASE_URL/api/debug
|
||||
curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug
|
||||
|
||||
# elif [[ $CMD == "createData" ]]; then
|
||||
|
||||
@@ -35,7 +32,7 @@ do
|
||||
rm -rf "$PROFILE_DIR"
|
||||
echo "config keychain.supported 0" >> "$CMD_FILE"
|
||||
echo "config sync.target 9" >> "$CMD_FILE"
|
||||
echo "config sync.9.path $API_BASE_URL" >> "$CMD_FILE"
|
||||
echo "config sync.9.path http://api.joplincloud.local:22300" >> "$CMD_FILE"
|
||||
echo "config sync.9.username $USER_EMAIL" >> "$CMD_FILE"
|
||||
echo "config sync.9.password 123456" >> "$CMD_FILE"
|
||||
|
||||
@@ -54,5 +51,5 @@ done
|
||||
cd "$ROOT_DIR/packages/app-cli"
|
||||
npm start -- --profile "$PROFILE_DIR" batch "$CMD_FILE"
|
||||
npm start -- --profile "$PROFILE_DIR" import ~/Desktop/Joplin_17_06_2021.jex
|
||||
# npm start -- --profile "$PROFILE_DIR" import ~/Desktop/Tout_18_06_2021.jex
|
||||
npm start -- --profile "$PROFILE_DIR" sync --use-lock 1
|
||||
npm start -- --profile "$PROFILE_DIR" sync
|
||||
|
||||
|
||||
2
packages/app-desktop/package-lock.json
generated
2
packages/app-desktop/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.1.3",
|
||||
"version": "2.0.11",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.1.3",
|
||||
"version": "2.1.0",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# It could be used to develop plugins too.
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
PLUGIN_PATH="$SCRIPT_DIR/../app-cli/tests/support/plugins/settings"
|
||||
PLUGIN_PATH="$SCRIPT_DIR/../app-cli/tests/support/plugins/external_assets"
|
||||
npm i --prefix="$PLUGIN_PATH" && npm start -- --dev-plugins "$PLUGIN_PATH"
|
||||
@@ -604,4 +604,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
PODFILE CHECKSUM: 6f558aa823c0fd07348253cc01996e4ee3de75c4
|
||||
|
||||
COCOAPODS: 1.8.4
|
||||
COCOAPODS: 1.10.1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
hash:"6608023b8053b48e0eec248644475e33", files: {
|
||||
hash:"45572502e8b0a8e9b85192de7291684c", files: {
|
||||
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
const BaseSyncTarget = require('./BaseSyncTarget').default;
|
||||
const Setting = require('./models/Setting').default;
|
||||
const { FileApi } = require('./file-api.js');
|
||||
const FileApiDriverMemory = require('./file-api-driver-memory').default;
|
||||
const { FileApiDriverMemory } = require('./file-api-driver-memory.js');
|
||||
const Synchronizer = require('./Synchronizer').default;
|
||||
|
||||
class SyncTargetMemory extends BaseSyncTarget {
|
||||
|
||||
@@ -19,8 +19,7 @@ import EncryptionService from './services/EncryptionService';
|
||||
import JoplinError from './JoplinError';
|
||||
import ShareService from './services/share/ShareService';
|
||||
import TaskQueue from './TaskQueue';
|
||||
import ItemUploader from './services/synchronizer/ItemUploader';
|
||||
import { FileApi } from './file-api';
|
||||
import { preUploadItems, serializeAndUploadItem } from './services/synchronizer/uploadUtils';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { Dirnames } = require('./services/synchronizer/utils/types');
|
||||
|
||||
@@ -28,18 +27,6 @@ interface RemoteItem {
|
||||
id: string;
|
||||
path?: string;
|
||||
type_?: number;
|
||||
isDeleted?: boolean;
|
||||
|
||||
// This the time when the file was created on the server. It is used for
|
||||
// example for the locking mechanim or any file that's not an actual Joplin
|
||||
// item.
|
||||
updated_time?: number;
|
||||
|
||||
// This is the time that corresponds to the actual Joplin item updated_time
|
||||
// value. A note is always uploaded with a delay so the server updated_time
|
||||
// value will always be ahead. However for synchronising we need to know the
|
||||
// exact Joplin item updated_time value.
|
||||
jop_updated_time?: number;
|
||||
}
|
||||
|
||||
function isCannotSyncError(error: any): boolean {
|
||||
@@ -63,7 +50,7 @@ export default class Synchronizer {
|
||||
public static verboseMode: boolean = true;
|
||||
|
||||
private db_: any;
|
||||
private api_: FileApi;
|
||||
private api_: any;
|
||||
private appType_: string;
|
||||
private logger_: Logger = new Logger();
|
||||
private state_: string = 'idle';
|
||||
@@ -87,7 +74,7 @@ export default class Synchronizer {
|
||||
|
||||
public dispatch: Function;
|
||||
|
||||
public constructor(db: any, api: FileApi, appType: string) {
|
||||
constructor(db: any, api: any, appType: string) {
|
||||
this.db_ = db;
|
||||
this.api_ = api;
|
||||
this.appType_ = appType;
|
||||
@@ -97,8 +84,6 @@ export default class Synchronizer {
|
||||
this.progressReport_ = {};
|
||||
|
||||
this.dispatch = function() {};
|
||||
|
||||
this.apiCall = this.apiCall.bind(this);
|
||||
}
|
||||
|
||||
state() {
|
||||
@@ -316,11 +301,11 @@ export default class Synchronizer {
|
||||
return '';
|
||||
}
|
||||
|
||||
private async apiCall(fnName: string, ...args: any[]) {
|
||||
async apiCall(fnName: string, ...args: any[]) {
|
||||
if (this.syncTargetIsLocked_) throw new JoplinError('Sync target is locked - aborting API call', 'lockError');
|
||||
|
||||
try {
|
||||
const output = await (this.api() as any)[fnName](...args);
|
||||
const output = await this.api()[fnName](...args);
|
||||
return output;
|
||||
} catch (error) {
|
||||
const lockStatus = await this.lockErrorStatus_();
|
||||
@@ -378,7 +363,7 @@ export default class Synchronizer {
|
||||
this.dispatch({ type: 'SYNC_STARTED' });
|
||||
eventManager.emit('syncStart');
|
||||
|
||||
this.logSyncOperation('starting', null, null, `Starting synchronisation to target ${syncTargetId}... supportsAccurateTimestamp = ${this.api().supportsAccurateTimestamp}; supportsMultiPut = ${this.api().supportsMultiPut} [${synchronizationId}]`);
|
||||
this.logSyncOperation('starting', null, null, `Starting synchronisation to target ${syncTargetId}... [${synchronizationId}]`);
|
||||
|
||||
const handleCannotSyncItem = async (ItemClass: any, syncTargetId: any, item: any, cannotSyncReason: string, itemLocation: any = null) => {
|
||||
await ItemClass.saveSyncDisabled(syncTargetId, item, cannotSyncReason, itemLocation);
|
||||
@@ -405,7 +390,11 @@ export default class Synchronizer {
|
||||
// correctly so as to share/unshare the right items.
|
||||
await Folder.updateAllShareIds();
|
||||
|
||||
const itemUploader = new ItemUploader(this.api(), this.apiCall);
|
||||
const uploadQueue = new TaskQueue('syncUpload', this.logger());
|
||||
|
||||
const uploadItem = (path: string, content: any) => {
|
||||
return this.apiCall('put', path, content);
|
||||
};
|
||||
|
||||
let errorToThrow = null;
|
||||
let syncLock = null;
|
||||
@@ -458,7 +447,7 @@ export default class Synchronizer {
|
||||
const result = await BaseItem.itemsThatNeedSync(syncTargetId);
|
||||
const locals = result.items;
|
||||
|
||||
await itemUploader.preUploadItems(result.items.filter((it: any) => result.neverSyncedItemIds.includes(it.id)));
|
||||
await preUploadItems(uploadItem, uploadQueue, result.items.filter((it: any) => result.neverSyncedItemIds.includes(it.id)));
|
||||
|
||||
for (let i = 0; i < locals.length; i++) {
|
||||
if (this.cancelling()) break;
|
||||
@@ -608,7 +597,7 @@ export default class Synchronizer {
|
||||
let canSync = true;
|
||||
try {
|
||||
if (this.testingHooks_.indexOf('notesRejectedByTarget') >= 0 && local.type_ === BaseModel.TYPE_NOTE) throw new JoplinError('Testing rejectedByTarget', 'rejectedByTarget');
|
||||
await itemUploader.serializeAndUploadItem(ItemClass, path, local);
|
||||
await serializeAndUploadItem(uploadItem, uploadQueue, ItemClass, path, local);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'rejectedByTarget') {
|
||||
await handleCannotSyncItem(ItemClass, syncTargetId, local, error.message);
|
||||
@@ -638,6 +627,7 @@ export default class Synchronizer {
|
||||
// above also doesn't use it because it fetches the whole remote object and read the
|
||||
// more reliable 'updated_time' property. Basically remote.updated_time is deprecated.
|
||||
|
||||
// await this.api().setTimestamp(path, local.updated_time);
|
||||
await ItemClass.saveSyncTime(syncTargetId, local, local.updated_time);
|
||||
}
|
||||
} else if (action == 'itemConflict') {
|
||||
@@ -782,27 +772,16 @@ export default class Synchronizer {
|
||||
logger: this.logger(),
|
||||
});
|
||||
|
||||
const remotes: RemoteItem[] = listResult.items;
|
||||
const remotes = listResult.items;
|
||||
|
||||
this.logSyncOperation('fetchingTotal', null, null, 'Fetching delta items from sync target', remotes.length);
|
||||
|
||||
const remoteIds = remotes.map(r => BaseItem.pathToId(r.path));
|
||||
const locals = await BaseItem.loadItemsByIds(remoteIds);
|
||||
|
||||
for (const remote of remotes) {
|
||||
if (this.cancelling()) break;
|
||||
|
||||
let needsToDownload = true;
|
||||
if (this.api().supportsAccurateTimestamp) {
|
||||
const local = locals.find(l => l.id === BaseItem.pathToId(remote.path));
|
||||
if (local && local.updated_time === remote.jop_updated_time) needsToDownload = false;
|
||||
}
|
||||
|
||||
if (needsToDownload) {
|
||||
this.downloadQueue_.push(remote.path, async () => {
|
||||
return this.apiCall('get', remote.path);
|
||||
});
|
||||
}
|
||||
this.downloadQueue_.push(remote.path, async () => {
|
||||
return this.apiCall('get', remote.path);
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < remotes.length; i++) {
|
||||
@@ -824,10 +803,9 @@ export default class Synchronizer {
|
||||
};
|
||||
|
||||
const path = remote.path;
|
||||
const remoteId = BaseItem.pathToId(path);
|
||||
let action = null;
|
||||
let reason = '';
|
||||
let local = locals.find(l => l.id === remoteId);
|
||||
let local = await BaseItem.loadItemByPath(path);
|
||||
let ItemClass = null;
|
||||
let content = null;
|
||||
|
||||
@@ -846,14 +824,10 @@ export default class Synchronizer {
|
||||
action = 'deleteLocal';
|
||||
reason = 'remote has been deleted';
|
||||
} else {
|
||||
if (this.api().supportsAccurateTimestamp && remote.jop_updated_time === local.updated_time) {
|
||||
// Nothing to do, and no need to fetch the content
|
||||
} else {
|
||||
content = await loadContent();
|
||||
if (content && content.updated_time > local.updated_time) {
|
||||
action = 'updateLocal';
|
||||
reason = 'remote is more recent than local';
|
||||
}
|
||||
content = await loadContent();
|
||||
if (content && content.updated_time > local.updated_time) {
|
||||
action = 'updateLocal';
|
||||
reason = 'remote is more recent than local';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { MultiPutItem } from './file-api';
|
||||
import JoplinError from './JoplinError';
|
||||
import JoplinServerApi from './JoplinServerApi';
|
||||
import Setting from './models/Setting';
|
||||
import { trimSlashes } from './path-utils';
|
||||
|
||||
// All input paths should be in the format: "path/to/file". This is converted to
|
||||
@@ -33,14 +31,6 @@ export default class FileApiDriverJoplinServer {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
public get supportsMultiPut() {
|
||||
return Setting.value('featureFlag.syncMultiPut');
|
||||
}
|
||||
|
||||
public get supportsAccurateTimestamp() {
|
||||
return Setting.value('featureFlag.syncAccurateTimestamps');
|
||||
}
|
||||
|
||||
public requestRepeatCount() {
|
||||
return 3;
|
||||
}
|
||||
@@ -49,8 +39,7 @@ export default class FileApiDriverJoplinServer {
|
||||
const output = {
|
||||
path: rootPath ? path.substr(rootPath.length + 1) : path,
|
||||
updated_time: md.updated_time,
|
||||
jop_updated_time: md.jop_updated_time,
|
||||
isDir: false,
|
||||
isDir: false, // !!md.is_directory,
|
||||
isDeleted: isDeleted,
|
||||
};
|
||||
|
||||
@@ -185,10 +174,6 @@ export default class FileApiDriverJoplinServer {
|
||||
}
|
||||
}
|
||||
|
||||
public async multiPut(items: MultiPutItem[], options: any = null) {
|
||||
return this.api().exec('PUT', 'api/batch_items', null, { items: items }, null, options);
|
||||
}
|
||||
|
||||
public async delete(path: string) {
|
||||
return this.api().exec('DELETE', this.apiFilePath_(path));
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import time from './time';
|
||||
const time = require('./time').default;
|
||||
const fs = require('fs-extra');
|
||||
import { basicDelta, MultiPutItem } from './file-api';
|
||||
|
||||
export default class FileApiDriverMemory {
|
||||
|
||||
private items_: any[];
|
||||
private deletedItems_: any[];
|
||||
const { basicDelta } = require('./file-api');
|
||||
|
||||
class FileApiDriverMemory {
|
||||
constructor() {
|
||||
this.items_ = [];
|
||||
this.deletedItems_ = [];
|
||||
}
|
||||
|
||||
encodeContent_(content: any) {
|
||||
encodeContent_(content) {
|
||||
if (content instanceof Buffer) {
|
||||
return content.toString('base64');
|
||||
} else {
|
||||
@@ -20,31 +16,23 @@ export default class FileApiDriverMemory {
|
||||
}
|
||||
}
|
||||
|
||||
public get supportsMultiPut() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public get supportsAccurateTimestamp() {
|
||||
return true;
|
||||
}
|
||||
|
||||
decodeContent_(content: any) {
|
||||
decodeContent_(content) {
|
||||
return Buffer.from(content, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
itemIndexByPath(path: string) {
|
||||
itemIndexByPath(path) {
|
||||
for (let i = 0; i < this.items_.length; i++) {
|
||||
if (this.items_[i].path == path) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
itemByPath(path: string) {
|
||||
itemByPath(path) {
|
||||
const index = this.itemIndexByPath(path);
|
||||
return index < 0 ? null : this.items_[index];
|
||||
}
|
||||
|
||||
newItem(path: string, isDir = false) {
|
||||
newItem(path, isDir = false) {
|
||||
const now = time.unixMs();
|
||||
return {
|
||||
path: path,
|
||||
@@ -55,18 +43,18 @@ export default class FileApiDriverMemory {
|
||||
};
|
||||
}
|
||||
|
||||
stat(path: string) {
|
||||
stat(path) {
|
||||
const item = this.itemByPath(path);
|
||||
return Promise.resolve(item ? Object.assign({}, item) : null);
|
||||
}
|
||||
|
||||
async setTimestamp(path: string, timestampMs: number): Promise<any> {
|
||||
async setTimestamp(path, timestampMs) {
|
||||
const item = this.itemByPath(path);
|
||||
if (!item) return Promise.reject(new Error(`File not found: ${path}`));
|
||||
item.updated_time = timestampMs;
|
||||
}
|
||||
|
||||
async list(path: string) {
|
||||
async list(path) {
|
||||
const output = [];
|
||||
|
||||
for (let i = 0; i < this.items_.length; i++) {
|
||||
@@ -89,7 +77,7 @@ export default class FileApiDriverMemory {
|
||||
});
|
||||
}
|
||||
|
||||
async get(path: string, options: any) {
|
||||
async get(path, options) {
|
||||
const item = this.itemByPath(path);
|
||||
if (!item) return Promise.resolve(null);
|
||||
if (item.isDir) return Promise.reject(new Error(`${path} is a directory, not a file`));
|
||||
@@ -105,13 +93,13 @@ export default class FileApiDriverMemory {
|
||||
return output;
|
||||
}
|
||||
|
||||
async mkdir(path: string) {
|
||||
async mkdir(path) {
|
||||
const index = this.itemIndexByPath(path);
|
||||
if (index >= 0) return;
|
||||
this.items_.push(this.newItem(path, true));
|
||||
}
|
||||
|
||||
async put(path: string, content: any, options: any = null) {
|
||||
async put(path, content, options = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
if (options.source === 'file') content = await fs.readFile(options.path);
|
||||
@@ -121,38 +109,13 @@ export default class FileApiDriverMemory {
|
||||
const item = this.newItem(path, false);
|
||||
item.content = this.encodeContent_(content);
|
||||
this.items_.push(item);
|
||||
return item;
|
||||
} else {
|
||||
this.items_[index].content = this.encodeContent_(content);
|
||||
this.items_[index].updated_time = time.unixMs();
|
||||
return this.items_[index];
|
||||
}
|
||||
}
|
||||
|
||||
public async multiPut(items: MultiPutItem[], options: any = null) {
|
||||
const output: any = {
|
||||
items: {},
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const processedItem = await this.put(`/root/${item.name}`, item.body, options);
|
||||
output.items[item.name] = {
|
||||
item: processedItem,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
output.items[item.name] = {
|
||||
item: null,
|
||||
error: error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async delete(path: string) {
|
||||
async delete(path) {
|
||||
const index = this.itemIndexByPath(path);
|
||||
if (index >= 0) {
|
||||
const item = Object.assign({}, this.items_[index]);
|
||||
@@ -163,10 +126,10 @@ export default class FileApiDriverMemory {
|
||||
}
|
||||
}
|
||||
|
||||
async move(oldPath: string, newPath: string): Promise<any> {
|
||||
async move(oldPath, newPath) {
|
||||
const sourceItem = this.itemByPath(oldPath);
|
||||
if (!sourceItem) return Promise.reject(new Error(`Path not found: ${oldPath}`));
|
||||
await this.delete(newPath); // Overwrite if newPath already exists
|
||||
this.delete(newPath); // Overwrite if newPath already exists
|
||||
sourceItem.path = newPath;
|
||||
}
|
||||
|
||||
@@ -174,8 +137,8 @@ export default class FileApiDriverMemory {
|
||||
this.items_ = [];
|
||||
}
|
||||
|
||||
async delta(path: string, options: any = null) {
|
||||
const getStatFn = async (path: string) => {
|
||||
async delta(path, options = null) {
|
||||
const getStatFn = async path => {
|
||||
const output = this.items_.slice();
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
const item = Object.assign({}, output[i]);
|
||||
@@ -193,3 +156,5 @@ export default class FileApiDriverMemory {
|
||||
this.items_ = [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { FileApiDriverMemory };
|
||||
@@ -11,11 +11,6 @@ const Mutex = require('async-mutex').Mutex;
|
||||
|
||||
const logger = Logger.create('FileApi');
|
||||
|
||||
export interface MultiPutItem {
|
||||
name: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function requestCanBeRepeated(error: any) {
|
||||
const errorCode = typeof error === 'object' && error.code ? error.code : null;
|
||||
|
||||
@@ -86,26 +81,6 @@ class FileApi {
|
||||
if (this.driver_.initialize) return this.driver_.initialize(this.fullPath(''));
|
||||
}
|
||||
|
||||
// This can be true if the driver implements uploading items in batch. Will
|
||||
// probably only be supported by Joplin Server.
|
||||
public get supportsMultiPut(): boolean {
|
||||
return !!this.driver().supportsMultiPut;
|
||||
}
|
||||
|
||||
// This can be true when the sync target timestamps (updated_time) provided
|
||||
// in the delta call are guaranteed to be accurate. That requires
|
||||
// explicitely setting the timestamp, which is not done anymore on any sync
|
||||
// target as it wasn't accurate (for example, the file system can't be
|
||||
// relied on, and even OneDrive for some reason doesn't guarantee that the
|
||||
// timestamp you set is what you get back).
|
||||
//
|
||||
// The only reliable one at the moment is Joplin Server since it reads the
|
||||
// updated_time property directly from the item (it unserializes it
|
||||
// server-side).
|
||||
public get supportsAccurateTimestamp(): boolean {
|
||||
return !!this.driver().supportsAccurateTimestamp;
|
||||
}
|
||||
|
||||
async fetchRemoteDateOffset_() {
|
||||
const tempFile = `${this.tempDirName()}/timeCheck${Math.round(Math.random() * 1000000)}.txt`;
|
||||
const startTime = Date.now();
|
||||
@@ -276,6 +251,12 @@ class FileApi {
|
||||
if (!output) return output;
|
||||
output.path = path;
|
||||
return output;
|
||||
|
||||
// return this.driver_.stat(this.fullPath(path)).then((output) => {
|
||||
// if (!output) return output;
|
||||
// output.path = path;
|
||||
// return output;
|
||||
// });
|
||||
}
|
||||
|
||||
// Returns UTF-8 encoded string by default, or a Response if `options.target = 'file'`
|
||||
@@ -296,11 +277,6 @@ class FileApi {
|
||||
return tryAndRepeat(() => this.driver_.put(this.fullPath(path), content, options), this.requestRepeatCount());
|
||||
}
|
||||
|
||||
public async multiPut(items: MultiPutItem[], options: any = null) {
|
||||
if (!this.driver().supportsMultiPut) throw new Error('Multi PUT not supported');
|
||||
return tryAndRepeat(() => this.driver_.multiPut(items, options), this.requestRepeatCount());
|
||||
}
|
||||
|
||||
delete(path: string) {
|
||||
logger.debug(`delete ${this.fullPath(path)}`);
|
||||
return tryAndRepeat(() => this.driver_.delete(this.fullPath(path)), this.requestRepeatCount());
|
||||
|
||||
@@ -403,7 +403,7 @@ export default class BaseItem extends BaseModel {
|
||||
return this.shareService_;
|
||||
}
|
||||
|
||||
public static async serializeForSync(item: BaseItemEntity): Promise<string> {
|
||||
public static async serializeForSync(item: BaseItemEntity) {
|
||||
const ItemClass = this.itemClass(item);
|
||||
const shownKeys = ItemClass.fieldNames();
|
||||
shownKeys.push('type_');
|
||||
|
||||
@@ -1242,21 +1242,6 @@ class Setting extends BaseModel {
|
||||
appTypes: ['desktop'],
|
||||
storage: SettingStorage.Database,
|
||||
},
|
||||
|
||||
'featureFlag.syncAccurateTimestamps': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: false,
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'featureFlag.syncMultiPut': {
|
||||
value: false,
|
||||
type: SettingItemType.Bool,
|
||||
public: false,
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
this.metadata_ = Object.assign(this.metadata_, this.customMetadata_);
|
||||
@@ -1363,18 +1348,10 @@ class Setting extends BaseModel {
|
||||
}
|
||||
|
||||
// Low-level method to load a setting directly from the database. Should not be used in most cases.
|
||||
public static async loadOne(key: string): Promise<CacheItem> {
|
||||
public static async loadOne(key: string) {
|
||||
if (this.keyStorage(key) === SettingStorage.File) {
|
||||
const fromFile = await this.fileHandler.load();
|
||||
return {
|
||||
key,
|
||||
value: fromFile[key],
|
||||
};
|
||||
} else if (this.settingMetadata(key).secure) {
|
||||
return {
|
||||
key,
|
||||
value: await this.keychainService().password(`setting.${key}`),
|
||||
};
|
||||
return fromFile[key];
|
||||
} else {
|
||||
return this.modelSelectOne('SELECT * FROM settings WHERE key = ?', [key]);
|
||||
}
|
||||
|
||||
2
packages/lib/package-lock.json
generated
2
packages/lib/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/lib",
|
||||
"version": "2.1.0",
|
||||
"version": "2.0.3",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -65,7 +65,6 @@ export default class JoplinSettings {
|
||||
if ('minimum' in setting) internalSettingItem.minimum = setting.minimum;
|
||||
if ('maximum' in setting) internalSettingItem.maximum = setting.maximum;
|
||||
if ('step' in setting) internalSettingItem.step = setting.step;
|
||||
if ('storage' in setting) internalSettingItem.storage = setting.storage;
|
||||
|
||||
await Setting.registerSetting(this.namespacedKey(key), internalSettingItem);
|
||||
}
|
||||
|
||||
@@ -334,11 +334,6 @@ export enum SettingItemType {
|
||||
Button = 6,
|
||||
}
|
||||
|
||||
export enum SettingStorage {
|
||||
Database = 1,
|
||||
File = 2,
|
||||
}
|
||||
|
||||
// Redefine a simplified interface to mask internal details
|
||||
// and to remove function calls as they would have to be async.
|
||||
export interface SettingItem {
|
||||
@@ -398,11 +393,6 @@ export interface SettingItem {
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
step?: number;
|
||||
|
||||
/**
|
||||
* Either store the setting in the database or in settings.json. Defaults to database.
|
||||
*/
|
||||
storage?: SettingStorage;
|
||||
}
|
||||
|
||||
export interface SettingSection {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { FileApi } from '../../file-api';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import Note from '../../models/Note';
|
||||
import { expectNotThrow, expectThrow, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||
import time from '../../time';
|
||||
import ItemUploader, { ApiCallFunction } from './ItemUploader';
|
||||
|
||||
interface ApiCall {
|
||||
name: string;
|
||||
args: any[];
|
||||
}
|
||||
|
||||
function clearArray(a: any[]) {
|
||||
a.splice(0, a.length);
|
||||
}
|
||||
|
||||
function newFakeApi(): FileApi {
|
||||
return { supportsMultiPut: true } as any;
|
||||
}
|
||||
|
||||
function newFakeApiCall(callRecorder: ApiCall[], itemBodyCallback: Function = null): ApiCallFunction {
|
||||
const apiCall = async (callName: string, ...args: any[]): Promise<any> => {
|
||||
callRecorder.push({ name: callName, args });
|
||||
|
||||
if (callName === 'multiPut') {
|
||||
const [batch] = args;
|
||||
const output: any = { items: {} };
|
||||
for (const item of batch) {
|
||||
if (itemBodyCallback) {
|
||||
output.items[item.name] = itemBodyCallback(item);
|
||||
} else {
|
||||
output.items[item.name] = {
|
||||
item: item.body,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
};
|
||||
return apiCall;
|
||||
}
|
||||
|
||||
describe('synchronizer_ItemUplader', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await setupDatabaseAndSynchronizer(2);
|
||||
await switchClient(1);
|
||||
done();
|
||||
});
|
||||
|
||||
it('should batch uploads and use the cache afterwards', (async () => {
|
||||
const callRecorder: ApiCall[] = [];
|
||||
const itemUploader = new ItemUploader(newFakeApi(), newFakeApiCall(callRecorder));
|
||||
|
||||
const notes = [
|
||||
await Note.save({ title: '1' }),
|
||||
await Note.save({ title: '2' }),
|
||||
];
|
||||
|
||||
await itemUploader.preUploadItems(notes);
|
||||
|
||||
// There should be only one call to "multiPut" because the items have
|
||||
// been batched.
|
||||
expect(callRecorder.length).toBe(1);
|
||||
expect(callRecorder[0].name).toBe('multiPut');
|
||||
|
||||
clearArray(callRecorder);
|
||||
|
||||
// Now if we try to upload the item it shouldn't call the API because it
|
||||
// will use the cached item.
|
||||
await itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[0]), notes[0]);
|
||||
expect(callRecorder.length).toBe(0);
|
||||
|
||||
// Now try to process a note that hasn't been cached. In that case, it
|
||||
// should call "PUT" directly.
|
||||
const note3 = await Note.save({ title: '3' });
|
||||
await itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(note3), note3);
|
||||
expect(callRecorder.length).toBe(1);
|
||||
expect(callRecorder[0].name).toBe('put');
|
||||
}));
|
||||
|
||||
it('should not batch upload if the items are over the batch size limit', (async () => {
|
||||
const callRecorder: ApiCall[] = [];
|
||||
const itemUploader = new ItemUploader(newFakeApi(), newFakeApiCall(callRecorder));
|
||||
itemUploader.maxBatchSize = 1;
|
||||
|
||||
const notes = [
|
||||
await Note.save({ title: '1' }),
|
||||
await Note.save({ title: '2' }),
|
||||
];
|
||||
|
||||
await itemUploader.preUploadItems(notes);
|
||||
expect(callRecorder.length).toBe(0);
|
||||
}));
|
||||
|
||||
it('should not use the cache if the note has changed since the pre-upload', (async () => {
|
||||
const callRecorder: ApiCall[] = [];
|
||||
const itemUploader = new ItemUploader(newFakeApi(), newFakeApiCall(callRecorder));
|
||||
|
||||
const notes = [
|
||||
await Note.save({ title: '1' }),
|
||||
await Note.save({ title: '2' }),
|
||||
];
|
||||
|
||||
await itemUploader.preUploadItems(notes);
|
||||
clearArray(callRecorder);
|
||||
|
||||
await itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[0]), notes[0]);
|
||||
expect(callRecorder.length).toBe(0);
|
||||
|
||||
await time.msleep(1);
|
||||
notes[1] = await Note.save({ title: '22' }),
|
||||
await itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[1]), notes[1]);
|
||||
expect(callRecorder.length).toBe(1);
|
||||
}));
|
||||
|
||||
it('should respect the max batch size', (async () => {
|
||||
const callRecorder: ApiCall[] = [];
|
||||
const itemUploader = new ItemUploader(newFakeApi(), newFakeApiCall(callRecorder));
|
||||
|
||||
const notes = [
|
||||
await Note.save({ title: '1' }),
|
||||
await Note.save({ title: '2' }),
|
||||
await Note.save({ title: '3' }),
|
||||
];
|
||||
|
||||
const noteSize = BaseItem.systemPath(notes[0]).length + (await Note.serializeForSync(notes[0])).length;
|
||||
itemUploader.maxBatchSize = noteSize * 2;
|
||||
|
||||
// It should send two batches - one with two notes, and the second with
|
||||
// only one note.
|
||||
await itemUploader.preUploadItems(notes);
|
||||
expect(callRecorder.length).toBe(2);
|
||||
expect(callRecorder[0].args[0].length).toBe(2);
|
||||
expect(callRecorder[1].args[0].length).toBe(1);
|
||||
}));
|
||||
|
||||
it('should rethrow error for items within the batch', (async () => {
|
||||
const callRecorder: ApiCall[] = [];
|
||||
|
||||
const notes = [
|
||||
await Note.save({ title: '1' }),
|
||||
await Note.save({ title: '2' }),
|
||||
await Note.save({ title: '3' }),
|
||||
];
|
||||
|
||||
// Simulates throwing an error on note 2
|
||||
const itemBodyCallback = (item: any): any => {
|
||||
if (item.name === BaseItem.systemPath(notes[1])) {
|
||||
return { error: new Error('Could not save item'), item: null };
|
||||
} else {
|
||||
return { error: null, item: item.body };
|
||||
}
|
||||
};
|
||||
|
||||
const itemUploader = new ItemUploader(newFakeApi(), newFakeApiCall(callRecorder, itemBodyCallback));
|
||||
|
||||
await itemUploader.preUploadItems(notes);
|
||||
|
||||
await expectNotThrow(async () => itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[0]), notes[0]));
|
||||
await expectThrow(async () => itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[1]), notes[1]));
|
||||
await expectNotThrow(async () => itemUploader.serializeAndUploadItem(Note, BaseItem.systemPath(notes[2]), notes[2]));
|
||||
}));
|
||||
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import { ModelType } from '../../BaseModel';
|
||||
import { FileApi, MultiPutItem } from '../../file-api';
|
||||
import Logger from '../../Logger';
|
||||
import BaseItem, { ItemThatNeedSync } from '../../models/BaseItem';
|
||||
|
||||
const logger = Logger.create('ItemUploader');
|
||||
|
||||
export type ApiCallFunction = (fnName: string, ...args: any[])=> Promise<any>;
|
||||
|
||||
interface BatchItem extends MultiPutItem {
|
||||
localItemUpdatedTime: number;
|
||||
}
|
||||
|
||||
export default class ItemUploader {
|
||||
|
||||
private api_: FileApi;
|
||||
private apiCall_: ApiCallFunction;
|
||||
private preUploadedItems_: Record<string, any> = {};
|
||||
private preUploadedItemUpdatedTimes_: Record<string, number> = {};
|
||||
private maxBatchSize_ = 1 * 1024 * 1024; // 1MB;
|
||||
|
||||
public constructor(api: FileApi, apiCall: ApiCallFunction) {
|
||||
this.api_ = api;
|
||||
this.apiCall_ = apiCall;
|
||||
}
|
||||
|
||||
public get maxBatchSize() {
|
||||
return this.maxBatchSize_;
|
||||
}
|
||||
|
||||
public set maxBatchSize(v: number) {
|
||||
this.maxBatchSize_ = v;
|
||||
}
|
||||
|
||||
public async serializeAndUploadItem(ItemClass: any, path: string, local: ItemThatNeedSync) {
|
||||
const preUploadItem = this.preUploadedItems_[path];
|
||||
if (preUploadItem) {
|
||||
if (this.preUploadedItemUpdatedTimes_[path] !== local.updated_time) {
|
||||
// Normally this should be rare as it can only happen if the
|
||||
// item has been changed between the moment it was pre-uploaded
|
||||
// and the moment where it's being processed by the
|
||||
// synchronizer. It could happen for example for a note being
|
||||
// edited just at the same time. In that case, we proceed with
|
||||
// the regular upload.
|
||||
logger.warn(`Pre-uploaded item updated_time has changed. It is going to be re-uploaded again: ${path} (From ${this.preUploadedItemUpdatedTimes_[path]} to ${local.updated_time})`);
|
||||
} else {
|
||||
if (preUploadItem.error) throw new Error(preUploadItem.error.message ? preUploadItem.error.message : 'Unknown pre-upload error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const content = await ItemClass.serializeForSync(local);
|
||||
await this.apiCall_('put', path, content);
|
||||
}
|
||||
|
||||
public async preUploadItems(items: ItemThatNeedSync[]) {
|
||||
if (!this.api_.supportsMultiPut) return;
|
||||
|
||||
const itemsToUpload: BatchItem[] = [];
|
||||
|
||||
for (const local of items) {
|
||||
// For resources, additional logic is necessary - in particular the blob
|
||||
// should be uploaded before the metadata, so we can't batch process.
|
||||
if (local.type_ === ModelType.Resource) continue;
|
||||
|
||||
const ItemClass = BaseItem.itemClass(local);
|
||||
itemsToUpload.push({
|
||||
name: BaseItem.systemPath(local),
|
||||
body: await ItemClass.serializeForSync(local),
|
||||
localItemUpdatedTime: local.updated_time,
|
||||
});
|
||||
}
|
||||
|
||||
let batchSize = 0;
|
||||
let currentBatch: BatchItem[] = [];
|
||||
|
||||
const uploadBatch = async (batch: BatchItem[]) => {
|
||||
for (const batchItem of batch) {
|
||||
this.preUploadedItemUpdatedTimes_[batchItem.name] = batchItem.localItemUpdatedTime;
|
||||
}
|
||||
|
||||
const response = await this.apiCall_('multiPut', batch);
|
||||
this.preUploadedItems_ = {
|
||||
...this.preUploadedItems_,
|
||||
...response.items,
|
||||
};
|
||||
};
|
||||
|
||||
while (itemsToUpload.length) {
|
||||
const itemToUpload = itemsToUpload.pop();
|
||||
const itemSize = itemToUpload.name.length + itemToUpload.body.length;
|
||||
|
||||
// Although it should be rare, if the item itself is above the
|
||||
// batch max size, we skip it. In that case it will be uploaded the
|
||||
// regular way when the synchronizer calls `serializeAndUploadItem()`
|
||||
if (itemSize > this.maxBatchSize) continue;
|
||||
|
||||
if (batchSize + itemSize > this.maxBatchSize) {
|
||||
await uploadBatch(currentBatch);
|
||||
batchSize = itemSize;
|
||||
currentBatch = [itemToUpload];
|
||||
} else {
|
||||
batchSize += itemSize;
|
||||
currentBatch.push(itemToUpload);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBatch.length) await uploadBatch(currentBatch);
|
||||
}
|
||||
|
||||
}
|
||||
30
packages/lib/services/synchronizer/uploadUtils.ts
Normal file
30
packages/lib/services/synchronizer/uploadUtils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ModelType } from '../../BaseModel';
|
||||
import BaseItem, { ItemThatNeedSync } from '../../models/BaseItem';
|
||||
import TaskQueue from '../../TaskQueue';
|
||||
|
||||
type UploadItem = (path: string, content: any)=> Promise<any>;
|
||||
|
||||
export async function serializeAndUploadItem(uploadItem: UploadItem, uploadQueue: TaskQueue, ItemClass: any, path: string, local: ItemThatNeedSync) {
|
||||
if (uploadQueue && uploadQueue.taskExists(path)) {
|
||||
return uploadQueue.taskResult(path);
|
||||
}
|
||||
|
||||
const content = await ItemClass.serializeForSync(local);
|
||||
return uploadItem(path, content);
|
||||
}
|
||||
|
||||
export async function preUploadItems(uploadItem: UploadItem, uploadQueue: TaskQueue, items: ItemThatNeedSync[]) {
|
||||
for (const local of items) {
|
||||
// For resources, additional logic is necessary - in particular the blob
|
||||
// should be uploaded before the metadata, so we can't batch process.
|
||||
if (local.type_ === ModelType.Resource) continue;
|
||||
|
||||
const ItemClass = BaseItem.itemClass(local);
|
||||
const path = BaseItem.systemPath(local);
|
||||
uploadQueue.push(path, async () => {
|
||||
await serializeAndUploadItem(uploadItem, null, ItemClass, path, local);
|
||||
});
|
||||
}
|
||||
|
||||
await uploadQueue.waitForAll();
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import Revision from '../models/Revision';
|
||||
import MasterKey from '../models/MasterKey';
|
||||
import BaseItem from '../models/BaseItem';
|
||||
const { FileApi } = require('../file-api.js');
|
||||
const FileApiDriverMemory = require('../file-api-driver-memory').default;
|
||||
const { FileApiDriverMemory } = require('../file-api-driver-memory.js');
|
||||
const { FileApiDriverLocal } = require('../file-api-driver-local.js');
|
||||
const { FileApiDriverWebDav } = require('../file-api-driver-webdav.js');
|
||||
const { FileApiDriverDropbox } = require('../file-api-driver-dropbox.js');
|
||||
|
||||
10
packages/renderer/assets/mermaid/mermaid.min.js
vendored
10
packages/renderer/assets/mermaid/mermaid.min.js
vendored
File diff suppressed because one or more lines are too long
20
packages/renderer/package-lock.json
generated
20
packages/renderer/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/renderer",
|
||||
"version": "2.1.0",
|
||||
"version": "2.0.3",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -4757,9 +4757,9 @@
|
||||
}
|
||||
},
|
||||
"khroma": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-1.4.1.tgz",
|
||||
"integrity": "sha512-+GmxKvmiRuCcUYDgR7g5Ngo0JEDeOsGdNONdU2zsiBQaK4z19Y2NvXqfEDE0ZiIrg45GTZyAnPLVsLZZACYm3Q=="
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-1.1.0.tgz",
|
||||
"integrity": "sha512-aTO+YX22tYOLEQJYFiatAj1lc5QZ+H5sHWFRBWNCiKwc5NWNUJZyeSeiHEPeURJ2a1GEVYcmyMUwGjjLe5ec5A=="
|
||||
},
|
||||
"kind-of": {
|
||||
"version": "6.0.3",
|
||||
@@ -4998,9 +4998,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"mermaid": {
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-8.10.2.tgz",
|
||||
"integrity": "sha512-Za5MrbAOMbEsyY4ONgGjfYz06sbwF1iNGRzp1sQqpOtvXxjxGu/J1jRJ8QyE9kD/D9zj1/KlRrYegWEvA7eZ5Q==",
|
||||
"version": "8.8.4",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-8.8.4.tgz",
|
||||
"integrity": "sha512-YPn35uEAIrOcsDPjCiKNXXBdO1Aoazsv2zTZjG4+oXa7+tTVUb5sI81NqaTYa47RnoH9Vl4waLlEEJfB8KM9VA==",
|
||||
"requires": {
|
||||
"@braintree/sanitize-url": "^3.1.0",
|
||||
"d3": "^5.7.0",
|
||||
@@ -6539,9 +6539,9 @@
|
||||
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
|
||||
},
|
||||
"uglify-js": {
|
||||
"version": "3.13.9",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.13.9.tgz",
|
||||
"integrity": "sha512-wZbyTQ1w6Y7fHdt8sJnHfSIuWeDgk6B5rCb4E/AM6QNNPbOMIZph21PW5dRB3h7Df0GszN+t7RuUH6sWK5bF0g=="
|
||||
"version": "3.12.1",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.12.1.tgz",
|
||||
"integrity": "sha512-o8lHP20KjIiQe5b/67Rh68xEGRrc2SRsCuuoYclXXoC74AfSRGblU1HKzJWH3HxPZ+Ort85fWHpSX7KwBUC9CQ=="
|
||||
},
|
||||
"union-value": {
|
||||
"version": "1.0.1",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"markdown-it-sup": "^1.0.0",
|
||||
"markdown-it-toc-done-right": "^4.1.0",
|
||||
"md5": "^2.2.1",
|
||||
"mermaid": "^8.10.2",
|
||||
"mermaid": "^8.8.4",
|
||||
"uslug": "git+https://github.com/laurent22/uslug.git#emoji-support"
|
||||
},
|
||||
"gitHead": "80c0089d2c52aff608b2bea74389de5a7f12f2e2"
|
||||
|
||||
2
packages/server/package-lock.json
generated
2
packages/server/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.1.3",
|
||||
"version": "2.1.1",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.1.3",
|
||||
"version": "2.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
|
||||
Binary file not shown.
@@ -356,7 +356,6 @@ export interface Item extends WithDates, WithUuid {
|
||||
jop_share_id?: Uuid;
|
||||
jop_type?: number;
|
||||
jop_encryption_applied?: number;
|
||||
jop_updated_time?: number;
|
||||
}
|
||||
|
||||
export interface UserItem extends WithDates {
|
||||
@@ -504,7 +503,6 @@ export const databaseSchema: DatabaseTables = {
|
||||
jop_share_id: { type: 'string' },
|
||||
jop_type: { type: 'number' },
|
||||
jop_encryption_applied: { type: 'number' },
|
||||
jop_updated_time: { type: 'number' },
|
||||
},
|
||||
user_items: {
|
||||
id: { type: 'number' },
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
await db.schema.alterTable('items', function(table: Knex.CreateTableBuilder) {
|
||||
table.bigInteger('jop_updated_time').defaultTo(0).notNullable();
|
||||
});
|
||||
|
||||
while (true) {
|
||||
const items = await db('items')
|
||||
.select('id', 'content')
|
||||
.where('jop_type', '>', 0)
|
||||
.andWhere('jop_updated_time', '=', 0)
|
||||
.limit(1000);
|
||||
|
||||
if (!items.length) break;
|
||||
|
||||
await db.transaction(async trx => {
|
||||
for (const item of items) {
|
||||
const unserialized = JSON.parse(item.content);
|
||||
await trx('items').update({ jop_updated_time: unserialized.updated_time }).where('id', '=', item.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(_db: DbConnection): Promise<any> {
|
||||
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import { ErrorResyncRequired } from '../utils/errors';
|
||||
import BaseModel, { SaveOptions } from './BaseModel';
|
||||
import { PaginatedResults, Pagination, PaginationOrderDir } from './utils/pagination';
|
||||
|
||||
export interface DeltaChange extends Change {
|
||||
jop_updated_time?: number;
|
||||
export interface ChangeWithItem {
|
||||
item: Item;
|
||||
updated_time: number;
|
||||
type: ChangeType;
|
||||
}
|
||||
|
||||
export interface PaginatedChanges extends PaginatedResults {
|
||||
items: DeltaChange[];
|
||||
items: Change[];
|
||||
}
|
||||
|
||||
export interface ChangePagination {
|
||||
@@ -156,20 +158,9 @@ export default class ChangeModel extends BaseModel<Change> {
|
||||
.orderBy('counter', 'asc')
|
||||
.limit(pagination.limit) as any[];
|
||||
|
||||
const changes: Change[] = await query;
|
||||
const changes = await query;
|
||||
|
||||
const items: Item[] = await this.db('items').select('id', 'jop_updated_time').whereIn('items.id', changes.map(c => c.item_id));
|
||||
|
||||
let finalChanges: DeltaChange[] = this.compressChanges(changes);
|
||||
finalChanges = await this.removeDeletedItems(finalChanges, items);
|
||||
finalChanges = finalChanges.map(c => {
|
||||
const item = items.find(item => item.id === c.item_id);
|
||||
if (!item) return c;
|
||||
return {
|
||||
...c,
|
||||
jop_updated_time: item.jop_updated_time,
|
||||
};
|
||||
});
|
||||
const finalChanges = await this.removeDeletedItems(this.compressChanges(changes));
|
||||
|
||||
return {
|
||||
items: finalChanges,
|
||||
@@ -180,14 +171,14 @@ export default class ChangeModel extends BaseModel<Change> {
|
||||
};
|
||||
}
|
||||
|
||||
private async removeDeletedItems(changes: Change[], items: Item[] = null): Promise<Change[]> {
|
||||
private async removeDeletedItems(changes: Change[]): Promise<Change[]> {
|
||||
const itemIds = changes.map(c => c.item_id);
|
||||
|
||||
// We skip permission check here because, when an item is shared, we need
|
||||
// to fetch files that don't belong to the current user. This check
|
||||
// would not be needed anyway because the change items are generated in
|
||||
// a context where permissions have already been checked.
|
||||
items = items === null ? await this.db('items').select('id').whereIn('items.id', itemIds) : items;
|
||||
const items: Item[] = await this.db('items').select('id').whereIn('items.id', itemIds);
|
||||
|
||||
const output: Change[] = [];
|
||||
|
||||
|
||||
@@ -285,7 +285,6 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
item.share_id = itemRow.jop_share_id;
|
||||
item.type_ = itemRow.jop_type;
|
||||
item.encryption_applied = itemRow.jop_encryption_applied;
|
||||
item.updated_time = itemRow.jop_updated_time;
|
||||
|
||||
return item;
|
||||
}
|
||||
@@ -337,7 +336,6 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
item.jop_type = joplinItem.type_;
|
||||
item.jop_encryption_applied = joplinItem.encryption_applied || 0;
|
||||
item.jop_share_id = joplinItem.share_id || '';
|
||||
item.jop_updated_time = joplinItem.updated_time;
|
||||
|
||||
const joplinItemToSave = { ...joplinItem };
|
||||
|
||||
@@ -346,7 +344,6 @@ export default class ItemModel extends BaseModel<Item> {
|
||||
delete joplinItemToSave.share_id;
|
||||
delete joplinItemToSave.type_;
|
||||
delete joplinItemToSave.encryption_applied;
|
||||
delete joplinItemToSave.updated_time;
|
||||
|
||||
item.content = Buffer.from(JSON.stringify(joplinItemToSave));
|
||||
} else {
|
||||
|
||||
@@ -5,17 +5,14 @@ import Router from '../../utils/Router';
|
||||
import { RouteType } from '../../utils/types';
|
||||
import { AppContext } from '../../utils/types';
|
||||
import * as fs from 'fs-extra';
|
||||
import { ErrorForbidden, ErrorMethodNotAllowed, ErrorNotFound, ErrorPayloadTooLarge } from '../../utils/errors';
|
||||
import { ErrorForbidden, ErrorMethodNotAllowed, ErrorNotFound } from '../../utils/errors';
|
||||
import ItemModel, { ItemSaveOption, SaveFromRawContentItem } from '../../models/ItemModel';
|
||||
import { requestDeltaPagination, requestPagination } from '../../models/utils/pagination';
|
||||
import { AclAction } from '../../models/BaseModel';
|
||||
import { safeRemove } from '../../utils/fileUtils';
|
||||
import { formatBytes, MB } from '../../utils/bytes';
|
||||
|
||||
const router = new Router(RouteType.Api);
|
||||
|
||||
const batchMaxSize = 1 * MB;
|
||||
|
||||
export async function putItemContents(path: SubPath, ctx: AppContext, isBatch: boolean) {
|
||||
if (!ctx.owner.can_upload) throw new ErrorForbidden('Uploading content is disabled');
|
||||
|
||||
@@ -26,16 +23,12 @@ export async function putItemContents(path: SubPath, ctx: AppContext, isBatch: b
|
||||
let items: SaveFromRawContentItem[] = [];
|
||||
|
||||
if (isBatch) {
|
||||
let totalSize = 0;
|
||||
items = bodyFields.items.map((item: any) => {
|
||||
totalSize += item.name.length + (item.body ? item.body.length : 0);
|
||||
return {
|
||||
name: item.name,
|
||||
body: item.body ? Buffer.from(item.body, 'utf8') : Buffer.alloc(0),
|
||||
};
|
||||
});
|
||||
|
||||
if (totalSize > batchMaxSize) throw new ErrorPayloadTooLarge(`Size of items (${formatBytes(totalSize)}) is over the limit (${formatBytes(batchMaxSize)})`);
|
||||
} else {
|
||||
const filePath = parsedBody?.files?.file ? parsedBody.files.file.path : null;
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { execCommand2, rootDir } from './tool-utils';
|
||||
|
||||
function getVersionFromTag(tagName: string, isPreRelease: boolean): string {
|
||||
function getVersionFromTag(tagName: string): string {
|
||||
if (tagName.indexOf('server-') !== 0) throw new Error(`Invalid tag: ${tagName}`);
|
||||
const s = tagName.split('-');
|
||||
const suffix = isPreRelease ? '-beta' : '';
|
||||
return s[1].substr(1) + suffix;
|
||||
return s[1].substr(1);
|
||||
}
|
||||
|
||||
function getIsPreRelease(tagName: string): boolean {
|
||||
@@ -16,8 +15,8 @@ async function main() {
|
||||
if (!argv.tagName) throw new Error('--tag-name not provided');
|
||||
|
||||
const tagName = argv.tagName;
|
||||
const imageVersion = getVersionFromTag(tagName);
|
||||
const isPreRelease = getIsPreRelease(tagName);
|
||||
const imageVersion = getVersionFromTag(tagName, isPreRelease);
|
||||
|
||||
process.chdir(rootDir);
|
||||
console.info(`Running from: ${process.cwd()}`);
|
||||
@@ -27,12 +26,10 @@ async function main() {
|
||||
console.info('isPreRelease:', isPreRelease);
|
||||
|
||||
await execCommand2(`docker build -t "joplin/server:${imageVersion}" -f Dockerfile.server .`);
|
||||
await execCommand2(`docker tag "joplin/server:${imageVersion}" "joplin/server:latest"`);
|
||||
await execCommand2(`docker push joplin/server:${imageVersion}`);
|
||||
|
||||
if (!isPreRelease) {
|
||||
await execCommand2(`docker tag "joplin/server:${imageVersion}" "joplin/server:latest"`);
|
||||
await execCommand2('docker push joplin/server:latest');
|
||||
}
|
||||
if (!isPreRelease) await execCommand2('docker push joplin/server:latest');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -14,17 +14,15 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Joplin-CLI 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: Francisco Mora <francisco.m.collao@gmail.com>\n"
|
||||
"Last-Translator: Mario Campo <mario.campo@gmail.com>\n"
|
||||
"Language-Team: Spanish <lucas.vieites@gmail.com>\n"
|
||||
"Language: es_ES\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 3.0\n"
|
||||
"X-Generator: Poedit 2.4.2\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Poedit-SourceCharset: UTF-8\n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
|
||||
#: packages/app-desktop/bridge.js:106 packages/app-desktop/bridge.js:110
|
||||
#: packages/app-desktop/bridge.js:126 packages/app-desktop/bridge.js:134
|
||||
@@ -133,11 +131,11 @@ msgstr "Descargar"
|
||||
|
||||
#: packages/app-desktop/checkForUpdates.js:189
|
||||
msgid "Skip this version"
|
||||
msgstr "Omitir esta versión"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/checkForUpdates.js:189
|
||||
msgid "Full changelog"
|
||||
msgstr "Registro de cambios completo"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteRevisionViewer.min.js:75
|
||||
#: packages/lib/services/RevisionService.js:242
|
||||
@@ -282,12 +280,14 @@ msgid "Retry"
|
||||
msgstr "Reintentar"
|
||||
|
||||
#: packages/app-desktop/gui/StatusScreen/StatusScreen.js:137
|
||||
#, fuzzy
|
||||
msgid "Advanced tools"
|
||||
msgstr "Herramientas avanzadas"
|
||||
msgstr "Opciones avanzadas"
|
||||
|
||||
#: packages/app-desktop/gui/StatusScreen/StatusScreen.js:139
|
||||
#, fuzzy
|
||||
msgid "Export debug report"
|
||||
msgstr "Exportar Informe de depuración"
|
||||
msgstr "Exportar Informe de Depuración"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js:183
|
||||
msgid "strong text"
|
||||
@@ -345,23 +345,23 @@ msgstr "Casillas"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:17
|
||||
msgid "Highlight"
|
||||
msgstr "Destacar"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:22
|
||||
msgid "Strikethrough"
|
||||
msgstr "Tachado"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:27
|
||||
msgid "Insert"
|
||||
msgstr "Insertar"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:33
|
||||
msgid "Superscript"
|
||||
msgstr "Superindice"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js:39
|
||||
msgid "Subscript"
|
||||
msgstr "Subíndice"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js:564
|
||||
#: packages/app-mobile/components/screens/Note.js:1016
|
||||
@@ -527,8 +527,9 @@ msgid "Delete line"
|
||||
msgstr "Borrar línea"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:92
|
||||
#, fuzzy
|
||||
msgid "Duplicate line"
|
||||
msgstr "Duplicar linea"
|
||||
msgstr "Duplicar"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/commands/editorCommandDeclarations.js:96
|
||||
msgid "Undo"
|
||||
@@ -721,12 +722,10 @@ msgid ""
|
||||
"Safe mode is currently active. Note rendering and all plugins are "
|
||||
"temporarily disabled."
|
||||
msgstr ""
|
||||
"El modo seguro está activo actualmente. La representación de notas y todos "
|
||||
"los complementos están temporalmente deshabilitados."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:447
|
||||
msgid "Disable safe mode and restart"
|
||||
msgstr "Desactivar el modo seguro y reiniciar"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:451
|
||||
msgid ""
|
||||
@@ -772,15 +771,15 @@ msgstr "Más información"
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:477
|
||||
#, javascript-format
|
||||
msgid "%s (%s) would like to share a notebook with you."
|
||||
msgstr "%s (%s) le gustaría compartir una libreta contigo."
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:479
|
||||
msgid "Accept"
|
||||
msgstr "Aceptar"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:481
|
||||
msgid "Reject"
|
||||
msgstr "Rechazar"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:485
|
||||
msgid "Some items cannot be synchronised."
|
||||
@@ -866,8 +865,9 @@ msgid "Toggle editors"
|
||||
msgstr "Alternar editores"
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/showShareFolderDialog.js:16
|
||||
#, fuzzy
|
||||
msgid "Share notebook..."
|
||||
msgstr "Compartir libreta..."
|
||||
msgstr "Compartir nota..."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js:16
|
||||
msgid "Change application layout"
|
||||
@@ -947,7 +947,7 @@ msgstr "¡El token ha sido copiado al portapapeles!"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:44
|
||||
msgid "Are you sure you want to renew the authorisation token?"
|
||||
msgstr "¿Está seguro de que desea renovar el token de autorización?"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:84
|
||||
msgid "The web clipper service is enabled and set to auto-start."
|
||||
@@ -1031,7 +1031,7 @@ msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:222
|
||||
msgid "Renew token"
|
||||
msgstr "Renovar token"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.js:167
|
||||
#, javascript-format
|
||||
@@ -1124,8 +1124,9 @@ msgid "&Go"
|
||||
msgstr "&Ir"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.js:631
|
||||
#, fuzzy
|
||||
msgid "Note&book"
|
||||
msgstr "Libreta"
|
||||
msgstr "Libretas"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.js:637
|
||||
msgid "&Note"
|
||||
@@ -1523,12 +1524,13 @@ msgid "You do not have any installed plugin."
|
||||
msgstr "No tiene ningún plugin instalado."
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:232
|
||||
#, fuzzy
|
||||
msgid "Could not connect to plugin repository"
|
||||
msgstr "No se pudo conectar con el repositorio del plugin: %s"
|
||||
msgstr "No se pudo instalar el plugin: %s"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:234
|
||||
msgid "Try again"
|
||||
msgstr "Intenta nuevamente"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js:242
|
||||
msgid "Plugin tools"
|
||||
@@ -1716,8 +1718,9 @@ msgstr ""
|
||||
"(límite: %s)."
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.js:141
|
||||
#, fuzzy
|
||||
msgid "Unshare note"
|
||||
msgstr "Dejar de compartir nota"
|
||||
msgstr "Compartir"
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.js:168
|
||||
msgid "Synchronising..."
|
||||
@@ -1752,20 +1755,19 @@ msgstr[0] "Copiar Enlace Compartible"
|
||||
msgstr[1] "Copiar Enlaces Compartible"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:138
|
||||
#, fuzzy
|
||||
msgid "Unshare"
|
||||
msgstr "Dejar de compartir"
|
||||
msgstr "Compartir"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:180
|
||||
msgid ""
|
||||
"Delete this invitation? The recipient will no longer have access to this "
|
||||
"shared notebook."
|
||||
msgstr ""
|
||||
"¿Eliminar esta invitación? El destinatario ya no tendrá acceso a esta "
|
||||
"libreta compartida."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:194
|
||||
msgid "Add recipient:"
|
||||
msgstr "Agregar destinatario:"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:197
|
||||
#: packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js:28
|
||||
@@ -1775,43 +1777,45 @@ msgstr "Compartir"
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:206
|
||||
msgid "Recipient has not yet accepted the invitation"
|
||||
msgstr "El destinatario aún no ha aceptado la invitación"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:207
|
||||
msgid "Recipient has rejected the invitation"
|
||||
msgstr "El destinatario ha rechazado la invitación"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:208
|
||||
msgid "Recipient has accepted the invitation"
|
||||
msgstr "El destinatario ha aceptado la invitación"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:218
|
||||
msgid "Recipients:"
|
||||
msgstr "Destinatarios:"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:230
|
||||
#, fuzzy
|
||||
msgid "Synchronizing..."
|
||||
msgstr "Sincronizando..."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:231
|
||||
#, fuzzy
|
||||
msgid "Sharing notebook..."
|
||||
msgstr "Compartir libreta..."
|
||||
msgstr "Compartir nota..."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:241
|
||||
msgid ""
|
||||
"Unshare this notebook? The recipients will no longer have access to its "
|
||||
"content."
|
||||
msgstr ""
|
||||
"¿Dejar de compartir esta libreta? Los destinatarios ya no tendrán acceso a "
|
||||
"su contenido."
|
||||
|
||||
#: packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js:251
|
||||
#, fuzzy
|
||||
msgid "Share Notebook"
|
||||
msgstr "Compartir Libreta"
|
||||
msgstr "Compartir Notas"
|
||||
|
||||
#: packages/app-desktop/commands/toggleSafeMode.js:18
|
||||
#, fuzzy
|
||||
msgid "Toggle safe mode"
|
||||
msgstr "Alternar modo seguro"
|
||||
msgstr "Alternar la barra lateral"
|
||||
|
||||
#: packages/app-desktop/commands/toggleExternalEditing.js:18
|
||||
msgid "Toggle external editing"
|
||||
@@ -1903,7 +1907,7 @@ msgstr "Configuración"
|
||||
|
||||
#: packages/app-mobile/components/side-menu-content.js:351
|
||||
msgid "Mobile data - auto-sync disabled"
|
||||
msgstr "Datos móviles: sincronización automática deshabilitada"
|
||||
msgstr ""
|
||||
|
||||
#: packages/app-mobile/components/note-list.js:97
|
||||
msgid "You currently have no notebooks."
|
||||
@@ -2253,15 +2257,15 @@ msgstr ""
|
||||
"cambiada! [Cambiarla ahora](%s)"
|
||||
|
||||
#: packages/server/dist/models/UserModel.js:134
|
||||
#, fuzzy
|
||||
msgid "attachment"
|
||||
msgstr "adjunto"
|
||||
msgstr "Adjuntos"
|
||||
|
||||
#: packages/server/dist/models/UserModel.js:134
|
||||
#, javascript-format
|
||||
msgid ""
|
||||
"Cannot save %s \"%s\" because it is larger than than the allowed limit (%s)"
|
||||
msgstr ""
|
||||
"No se puede guardar %s \"%s\" porque es mayor que el límite permitido (%s)"
|
||||
|
||||
#: packages/lib/onedrive-api-node-utils.js:46
|
||||
#, javascript-format
|
||||
@@ -2452,20 +2456,23 @@ msgid "Joplin Server URL"
|
||||
msgstr "URL del Servidor de Joplin"
|
||||
|
||||
#: packages/lib/models/Setting.js:327
|
||||
#, fuzzy
|
||||
msgid "Joplin Server email"
|
||||
msgstr "Email del servidor Joplin"
|
||||
msgstr "Servidor de Joplin"
|
||||
|
||||
#: packages/lib/models/Setting.js:338
|
||||
msgid "Joplin Server password"
|
||||
msgstr "Contraseña del Servidor de Joplin"
|
||||
|
||||
#: packages/lib/models/Setting.js:365
|
||||
#, fuzzy
|
||||
msgid "Joplin Cloud email"
|
||||
msgstr "Email del servidor Joplin"
|
||||
msgstr "Servidor de Joplin"
|
||||
|
||||
#: packages/lib/models/Setting.js:376
|
||||
#, fuzzy
|
||||
msgid "Joplin Cloud password"
|
||||
msgstr "Contraseña del servidor Joplin"
|
||||
msgstr "Contraseña del Servidor de Joplin"
|
||||
|
||||
#: packages/lib/models/Setting.js:388
|
||||
msgid "Attachment download behaviour"
|
||||
@@ -2707,12 +2714,11 @@ msgid ""
|
||||
"Used for most text in the markdown editor. If not found, a generic "
|
||||
"proportional (variable width) font is used."
|
||||
msgstr ""
|
||||
"Se utiliza para la mayor parte del texto en el editor markdown. Si no se "
|
||||
"encuentra, se utiliza una fuente genérica proporcional (ancho variable)."
|
||||
|
||||
#: packages/lib/models/Setting.js:758
|
||||
#, fuzzy
|
||||
msgid "Editor monospace font family"
|
||||
msgstr "Familia de fuente monoespaciada del editor"
|
||||
msgstr "Familia de fuente del editor"
|
||||
|
||||
#: packages/lib/models/Setting.js:759
|
||||
msgid ""
|
||||
@@ -2720,10 +2726,6 @@ msgid ""
|
||||
"tables, checkboxes, code). If not found, a generic monospace (fixed width) "
|
||||
"font is used."
|
||||
msgstr ""
|
||||
"Se utiliza cuando se necesita una fuente de ancho fijo para presentar el "
|
||||
"texto de manera legible (por ejemplo, tablas, casillas de verificación, "
|
||||
"código). Si no se encuentra, se utiliza una fuente genérica monoespaciada "
|
||||
"(ancho fijo)."
|
||||
|
||||
#: packages/lib/models/Setting.js:780
|
||||
msgid "Custom stylesheet for rendered Markdown"
|
||||
@@ -2735,13 +2737,11 @@ msgstr "Hoja de estilos para personalizar todo Joplin"
|
||||
|
||||
#: packages/lib/models/Setting.js:806
|
||||
msgid "Re-upload local data to sync target"
|
||||
msgstr "Vuelva a cargar datos locales para sincronizar el destino"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/models/Setting.js:816
|
||||
msgid "Delete local data and re-download from sync target"
|
||||
msgstr ""
|
||||
"Elimine los datos locales y vuelva a descargarlos desde el destino de "
|
||||
"sincronización"
|
||||
|
||||
#: packages/lib/models/Setting.js:821
|
||||
msgid "Automatically update the application"
|
||||
@@ -2778,7 +2778,7 @@ msgstr "%d horas"
|
||||
|
||||
#: packages/lib/models/Setting.js:849
|
||||
msgid "Synchronise only over WiFi connection"
|
||||
msgstr "Sincronizar solo a través de una conexión WiFi"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/models/Setting.js:856
|
||||
msgid "Text editor command"
|
||||
@@ -3064,8 +3064,9 @@ msgid "Encrypted items cannot be modified"
|
||||
msgstr "Los elementos cifrados no pueden ser modificados"
|
||||
|
||||
#: packages/lib/SyncTargetJoplinCloud.js:28
|
||||
#, fuzzy
|
||||
msgid "Joplin Cloud"
|
||||
msgstr "Nube de Joplin"
|
||||
msgstr "Foro de Joplin"
|
||||
|
||||
#: packages/lib/BaseApplication.js:152 packages/lib/BaseApplication.js:164
|
||||
#: packages/lib/BaseApplication.js:196
|
||||
@@ -3223,15 +3224,16 @@ msgstr ""
|
||||
"Joplin a la última versión"
|
||||
|
||||
#: packages/lib/JoplinServerApi.js:80
|
||||
#, javascript-format
|
||||
#, fuzzy, javascript-format
|
||||
msgid ""
|
||||
"Could not connect to Joplin Server. Please check the Synchronisation options "
|
||||
"in the config screen. Full error was:\n"
|
||||
"\n"
|
||||
"%s"
|
||||
msgstr ""
|
||||
"No se pudo conectar al servidor Joplin. Compruebe las opciones de "
|
||||
"sincronización en la pantalla de configuración. El error completo fue:\n"
|
||||
"No se pudo conectar con la aplicación de Joplin de Nextcloud. Verifique la "
|
||||
"configuración en la pantalla de configuración de Sincronización. El error "
|
||||
"completo fue:\n"
|
||||
"\n"
|
||||
"%s"
|
||||
|
||||
@@ -3906,8 +3908,6 @@ msgid ""
|
||||
"Runs the commands contained in the text file. There should be one command "
|
||||
"per line."
|
||||
msgstr ""
|
||||
"Ejecuta los comandos contenidos en el archivo de texto. Debe haber un "
|
||||
"comando por línea."
|
||||
|
||||
#: packages/app-cli/app/command-version.js:11
|
||||
msgid "Displays version information"
|
||||
|
||||
@@ -6,15 +6,13 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Joplin-CLI 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: 南宫小骏 <jackytsu@vip.qq.com>\n"
|
||||
"Language-Team: zh_CN <jackytsu.vip.qq.com>\n"
|
||||
"Language: zh_CN\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 3.0\n"
|
||||
"X-Generator: Poedit 2.4.3\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
#: packages/app-desktop/bridge.js:106 packages/app-desktop/bridge.js:110
|
||||
@@ -1089,8 +1087,9 @@ msgid "&Go"
|
||||
msgstr "跳转 (&G)"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.js:631
|
||||
#, fuzzy
|
||||
msgid "Note&book"
|
||||
msgstr "笔记本 (&B)"
|
||||
msgstr "笔记本"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.js:637
|
||||
msgid "&Note"
|
||||
@@ -1659,8 +1658,9 @@ msgid "Warning: not all resources shown for performance reasons (limit: %s)."
|
||||
msgstr "警告: 由于性能原因无法显示所有资源 (最多:%s)。"
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.js:141
|
||||
#, fuzzy
|
||||
msgid "Unshare note"
|
||||
msgstr "取消分享笔记"
|
||||
msgstr "取消分享"
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.js:168
|
||||
msgid "Synchronising..."
|
||||
@@ -2172,6 +2172,7 @@ msgid ""
|
||||
msgstr "默认管理员密码不安全且尚未更改"
|
||||
|
||||
#: packages/server/dist/models/UserModel.js:134
|
||||
#, fuzzy
|
||||
msgid "attachment"
|
||||
msgstr "附件"
|
||||
|
||||
@@ -2179,7 +2180,7 @@ msgstr "附件"
|
||||
#, javascript-format
|
||||
msgid ""
|
||||
"Cannot save %s \"%s\" because it is larger than than the allowed limit (%s)"
|
||||
msgstr "无法保存 %s “%s”,因为超过了允许的限制大小(%s)。"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/onedrive-api-node-utils.js:46
|
||||
#, javascript-format
|
||||
@@ -2369,12 +2370,14 @@ msgid "Joplin Server password"
|
||||
msgstr "Joplin 服务器密码"
|
||||
|
||||
#: packages/lib/models/Setting.js:365
|
||||
#, fuzzy
|
||||
msgid "Joplin Cloud email"
|
||||
msgstr "Joplin 论坛邮箱"
|
||||
msgstr "Joplin Server 邮箱"
|
||||
|
||||
#: packages/lib/models/Setting.js:376
|
||||
#, fuzzy
|
||||
msgid "Joplin Cloud password"
|
||||
msgstr "Joplin 论坛密码"
|
||||
msgstr "Joplin 服务器密码"
|
||||
|
||||
#: packages/lib/models/Setting.js:388
|
||||
msgid "Attachment download behaviour"
|
||||
@@ -2953,6 +2956,7 @@ msgid "Encrypted items cannot be modified"
|
||||
msgstr "无法修改已加密的条目"
|
||||
|
||||
#: packages/lib/SyncTargetJoplinCloud.js:28
|
||||
#, fuzzy
|
||||
msgid "Joplin Cloud"
|
||||
msgstr "Joplin 论坛"
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# Joplin Server Changelog
|
||||
|
||||
## [server-v2.1.3-beta](https://github.com/laurent22/joplin/releases/tag/server-v2.1.3-beta) (Pre-release) - 2021-06-19T14:15:06Z
|
||||
|
||||
- New: Add support for uploading multiple items in one request (3b9c02e)
|
||||
|
||||
## [server-v2.1.1](https://github.com/laurent22/joplin/releases/tag/server-v2.1.1) - 2021-06-17T17:27:29Z
|
||||
|
||||
- New: Added account info to dashboard and title to pages (7f0b3fd)
|
||||
|
||||
Reference in New Issue
Block a user