1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-01-05 00:12:33 +02:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Laurent Cozic
e81427a1f2 Merge branch 'dev' into sync_batch_upload 2021-06-18 11:50:41 +01:00
Laurent Cozic
958e9163b6 All: Batch upload during initial sync 2021-06-17 12:45:34 +01:00
40 changed files with 233 additions and 718 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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 = () => {

View File

@@ -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);
},
});

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "2.1.3",
"version": "2.0.11",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -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,

View File

@@ -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"

View File

@@ -604,4 +604,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 6f558aa823c0fd07348253cc01996e4ee3de75c4
COCOAPODS: 1.8.4
COCOAPODS: 1.10.1

View File

@@ -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

View File

@@ -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 {

View File

@@ -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';
}
}
}

View File

@@ -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));
}

View File

@@ -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 };

View File

@@ -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());

View File

@@ -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_');

View File

@@ -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]);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/lib",
"version": "2.1.0",
"version": "2.0.3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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]));
}));
});

View File

@@ -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);
}
}

View 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();
}

View File

@@ -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');

File diff suppressed because one or more lines are too long

View File

@@ -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",

View File

@@ -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"

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/server",
"version": "2.1.3",
"version": "2.1.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -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.

View File

@@ -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' },

View File

@@ -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> {
}

View File

@@ -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[] = [];

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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"

View File

@@ -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 "默认管理员密码不安全且尚未更改![现在更改](%s)"
#: 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 论坛"

View File

@@ -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)