API: add autosave support

This commit is contained in:
yflory 2023-01-26 18:40:31 +01:00 committed by Wolfgang Ginolas
parent da90cb233d
commit 592e5690df
12 changed files with 535 additions and 17 deletions

View file

@ -560,7 +560,7 @@ define([
I used 'apply' with 'arguments' to avoid breaking things if this API ever changes.
*/
var ret = CodeMirror.fileImporter.apply(null, Array.prototype.slice.call(arguments));
previewPane.modeChange(ret.mode);
previewPane.modeChange(ret.highlightMode);
return ret;
});

View file

@ -77,7 +77,7 @@
},
unreg: function (cb) {
if (handlers.indexOf(cb) === -1) {
return void console.error("event handler was already unregistered");
return void console.log("event handler was already unregistered");
}
handlers.splice(handlers.indexOf(cb), 1);
},

View file

@ -14,6 +14,7 @@ define([
'/common/outer/cache-store.js',
'/common/outer/sharedfolder.js',
'/common/outer/cursor.js',
'/common/outer/integration.js',
'/common/outer/onlyoffice.js',
'/common/outer/mailbox.js',
'/common/outer/profile.js',
@ -33,7 +34,7 @@ define([
'/bower_components/saferphore/index.js',
], function (ApiConfig, Sortify, UserObject, ProxyManager, Migrate, Hash, Util, Constants, Feedback,
Realtime, Messaging, Pinpad, Cache,
SF, Cursor, OnlyOffice, Mailbox, Profile, Team, Messenger, History,
SF, Cursor, Integration, OnlyOffice, Mailbox, Profile, Team, Messenger, History,
Calendar, NetConfig, AppConfig,
Crypto, ChainPad, CpNetflux, Listmap, Netflux, nThen, Saferphore) {
@ -2471,6 +2472,9 @@ define([
try {
store.modules['cursor'].leavePad(chanId);
} catch (e) { console.error(e); }
try {
store.modules['integration'].leavePad(chanId);
} catch (e) { console.error(e); }
try {
store.onlyoffice.leavePad(chanId);
} catch (e) { console.error(e); }
@ -2795,6 +2799,7 @@ define([
postMessage(clientId, 'LOADING_DRIVE', data);
});
loadUniversal(Cursor, 'cursor', waitFor);
loadUniversal(Integration, 'integration', waitFor);
loadOnlyOffice();
loadUniversal(Messenger, 'messenger', waitFor);
store.messenger = store.modules['messenger'];
@ -3110,6 +3115,7 @@ define([
// To be able to use all the features inside the pad, we need to
// initialize the chat (messenger) and the cursor modules.
loadUniversal(Cursor, 'cursor', function () {});
loadUniversal(Integration, 'integration', function () {});
loadUniversal(Messenger, 'messenger', function () {});
store.messenger = store.modules['messenger'];

View file

@ -0,0 +1,207 @@
define([
'/common/common-util.js',
'/common/common-constants.js',
'/customize/messages.js',
'/customize/application_config.js',
'/bower_components/chainpad-crypto/crypto.js',
], function (Util, Constants, Messages, AppConfig, Crypto) {
var Integration = {};
var convertToUint8 = function (obj) {
var l = Object.keys(obj).length;
var u = new Uint8Array(l);
for (var i = 0; i<l; i++) {
u[i] = obj[i];
}
return u;
};
var sendMsg = function (ctx, data, client, cb) {
console.error(data, client)
var c = ctx.clients[client];
if (!c) { return void cb({error: 'NO_CLIENT'}); }
var chan = ctx.channels[c.channel];
if (!chan) { return void cb({error: 'NO_CHAN'}); }
var obj = {
id: client,
msg: data.msg,
uid: data.uid,
};
chan.sendMsg(JSON.stringify(obj), cb);
ctx.emit('MESSAGE', obj, chan.clients.filter(function (cl) {
return cl !== client;
}));
};
var initIntegration = function (ctx, obj, client, cb) {
var channel = obj.channel;
var secret = obj.secret;
console.error('INIT INTE', channel, secret.channel);
if (secret.keys.cryptKey) {
secret.keys.cryptKey = convertToUint8(secret.keys.cryptKey);
}
var padChan = secret.channel;
var network = ctx.store.network;
var first = true;
var c = ctx.clients[client];
if (!c) {
c = ctx.clients[client] = {
channel: channel
};
} else {
return void cb();
}
var chan = ctx.channels[channel];
if (chan) {
// This channel is already open in another tab
// ==> Set the ID to our client object
if (!c.id) { c.id = chan.wc.myID + '-' + client; }
// ==> And push the new tab to the list
chan.clients.push(client);
return void cb();
}
var onOpen = function (wc) {
ctx.channels[channel] = ctx.channels[channel] || {};
var chan = ctx.channels[channel];
chan.padChan = padChan;
if (!c.id) { c.id = wc.myID + '-' + client; }
if (chan.clients) {
// If 2 tabs from the same worker have been opened at the same time,
// we have to fix both of them
chan.clients.forEach(function (cl) {
if (ctx.clients[cl] && !ctx.clients[cl].id) {
ctx.clients[cl].id = wc.myID + '-' + cl;
}
});
}
if (!chan.encryptor) { chan.encryptor = Crypto.createEncryptor(secret.keys); }
wc.on('message', function (cryptMsg) {
var msg = chan.encryptor.decrypt(cryptMsg, secret.keys && secret.keys.validateKey);
var parsed;
try {
parsed = JSON.parse(msg);
ctx.emit('MESSAGE', parsed, chan.clients);
} catch (e) { console.error(e); }
});
chan.wc = wc;
chan.sendMsg = function (msg, cb) {
cb = cb || function () {};
var cmsg = chan.encryptor.encrypt(msg);
wc.bcast(cmsg).then(function () {
cb();
}, function (err) {
cb({error: err});
});
};
if (!first) { return; }
chan.clients = [client];
first = false;
cb();
};
network.join(channel).then(onOpen, function (err) {
return void cb({error: err});
});
var onReconnect = function () {
if (!ctx.channels[channel]) { console.log("cant reconnect", channel); return; }
network.join(channel).then(onOpen, function (err) {
console.error(err);
});
};
ctx.channels[channel] = ctx.channels[channel] || {};
ctx.channels[channel].onReconnect = onReconnect;
network.on('reconnect', onReconnect);
};
var leaveChannel = function (ctx, padChan) {
// Leave channel and prevent reconnect when we leave a pad
Object.keys(ctx.channels).some(function (cursorChan) {
var channel = ctx.channels[cursorChan];
if (channel.padChan !== padChan) { return; }
if (channel.wc) { channel.wc.leave(); }
if (channel.onReconnect) {
var network = ctx.store.network;
network.off('reconnect', channel.onReconnect);
}
delete ctx.channels[cursorChan];
return true;
});
};
// Remove the client from all its channels when a tab is closed
var removeClient = function (ctx, clientId) {
var filter = function (c) {
return c !== clientId;
};
// Remove the client from our channels
var chan;
for (var k in ctx.channels) {
chan = ctx.channels[k];
chan.clients = chan.clients.filter(filter);
if (chan.clients.length === 0) {
if (chan.wc) { chan.wc.leave(); }
if (chan.onReconnect) {
var network = ctx.store.network;
network.off('reconnect', chan.onReconnect);
}
delete ctx.channels[k];
}
}
delete ctx.clients[clientId];
};
Integration.init = function (cfg, waitFor, emit) {
var integration = {};
// Already initialized by a "noDrive" tab?
if (cfg.store && cfg.store.modules && cfg.store.modules['integration']) {
return cfg.store.modules['integration'];
}
var ctx = {
store: cfg.store,
emit: emit,
channels: {},
clients: {}
};
integration.removeClient = function (clientId) {
removeClient(ctx, clientId);
};
integration.leavePad = function (padChan) {
leaveChannel(ctx, padChan);
};
integration.execCommand = function (clientId, obj, cb) {
var cmd = obj.cmd;
var data = obj.data;
if (cmd === 'INIT') {
return void initIntegration(ctx, data, clientId, cb);
}
if (cmd === 'SEND') {
return void sendMsg(ctx, data, clientId, cb);
}
};
return integration;
};
return Integration;
});

View file

@ -63,6 +63,7 @@ define([
var create = function (options, cb) {
var evContentUpdate = Util.mkEvent();
var evIntegrationSave = Util.mkEvent();
var evCursorUpdate = Util.mkEvent();
var evEditableStateChange = Util.mkEvent();
var evOnReady = Util.mkEvent(true);
@ -71,7 +72,7 @@ define([
var evStart = Util.mkEvent(true);
var mediaTagEmbedder;
var fileImporter;
var fileImporter, fileExporter;
var $embedButton;
var common;
@ -82,6 +83,7 @@ define([
var toolbar;
var state = STATE.DISCONNECTED;
var firstConnection = true;
var integration;
var toolbarContainer = options.toolbarContainer ||
(function () { throw new Error("toolbarContainer must be specified"); }());
@ -234,8 +236,12 @@ define([
var oldContent;
var contentUpdate = function (newContent, waitFor) {
if (JSONSortify(newContent) === JSONSortify(oldContent)) { return; }
var sNew = JSONSortify(newContent);
if (sNew === JSONSortify(oldContent)) { return; }
try {
if (sNew !== JSONSortify(normalize(oldContent || {}))) {
evIntegrationSave.fire();
}
evContentUpdate.fire(newContent, waitFor);
oldContent = newContent;
} catch (e) {
@ -396,6 +402,10 @@ define([
};
*/
var integrationOnPatch = function () {
cpNfInner.offPatchSent(integrationOnPatch);
evIntegrationSave.fire();
};
onLocal = function (/*padChange*/) {
if (unsyncMode) { return; }
if (state !== STATE.READY) { return; }
@ -414,8 +424,14 @@ define([
//cpNfInner.metadataMgr.addAuthor();
}
*/
if (integration && oldContent && JSONSortify(content) !== JSONSortify(normalize(oldContent || {}))) {
cpNfInner.offPatchSent(integrationOnPatch);
cpNfInner.onPatchSent(integrationOnPatch);
}
oldContent = content;
if (Array.isArray(content)) {
// Pad
content.push({ metadata: cpNfInner.metadataMgr.getMetadataLazy() });
@ -568,11 +584,14 @@ define([
}
if (priv.initialState) {
var blob = priv.initialState;
var file = new File([blob], blob.name);
UIElements.importContent('text/plain', fileImporter, {})(file);
var file = new File([blob], 'document.'+priv.integrationConfig.fileType);
stateChange(STATE.READY); // Required for fileImporter
UIElements.importContent('text/plain', waitFor(fileImporter), {})(file);
title.updateTitle(file.name);
} else {
title.updateTitle(title.defaultTitle);
evOnDefaultContentNeeded.fire();
}
title.updateTitle(title.defaultTitle);
evOnDefaultContentNeeded.fire();
}
}).nThen(function () {
// We have a valid chainpad, reenable cache fix in case we reconnect with
@ -602,6 +621,39 @@ define([
common.getMetadataMgr().setDegraded(false);
}
if (privateDat.integration) {
common.openIntegrationChannel(onLocal);
var integrationSave = function (cb) {
var ext = privateDat.integrationConfig.fileType;
var upload = Util.once(function (_blob) {
var sframeChan = common.getSframeChannel();
sframeChan.query('Q_INTEGRATION_SAVE', {
blob: _blob
}, cb, {
raw: true
});
});
// "fe" (fileExpoter) can be sync or async depending on the app
// we need to handle both cases
var syncBlob = fileExporter(function (asyncBlob) {
upload(asyncBlob);
}, ext);
if (syncBlob) {
upload(syncBlob);
}
};
var inte = common.createIntegration(onLocal, cpNfInner.chainpad,
integrationSave, toolbar);
if (inte) {
integration = true;
evIntegrationSave.reg(function () {
inte.changed();
});
}
}
UI.removeLoadingScreen(emitResize);
if (AppConfig.textAnalyzer && textContentGetter) {
@ -643,6 +695,7 @@ define([
};
var setFileExporter = function (extension, fe, async) {
fileExporter = fe;
var $export = common.createButton('export', true, {}, function () {
var ext = (typeof(extension) === 'function') ? extension() : extension;
var suggestion = title.suggestTitle('cryptpad-document');
@ -718,7 +771,7 @@ define([
if (async) {
fi(c, f, function (content) {
nThen(function (waitFor) {
contentUpdate(content, waitFor);
contentUpdate(normalize(content), waitFor);
}).nThen(function () {
onLocal();
});
@ -730,7 +783,7 @@ define([
if (typeof(content) === "undefined") {
return void UI.warn(Messages.importError);
}
contentUpdate(content, waitFor);
contentUpdate(normalize(content), waitFor);
}).nThen(function () {
onLocal();
});

View file

@ -30,6 +30,7 @@ define([
messaging: true,
integration: isIntegration,
integrationUtils: integration.utils,
integrationConfig: integration.config || {},
initialState: integration.initialState || undefined
});
});

View file

@ -485,7 +485,7 @@ define([
$toolbarContainer.find('#language-mode').val('text');
}
// return the mode so that the code editor can decide how to display the new content
return { content: content, mode: mode };
return { content: content, highlightMode: mode, authormarks: {} };
};
exp.setValueAndCursor = function (oldDoc, remoteDoc) {

View file

@ -0,0 +1,205 @@
define([
'/common/common-util.js',
], function (Util) {
var module = {};
module.create = function (Common, onLocal, chainpad, saveHandler, toolbar) {
var exp = {};
var metadataMgr = Common.getMetadataMgr();
var privateData = metadataMgr.getPrivateData();
// XXX
// from privateData, get the autosave timer
// get "chainpad" to detect content changes
// when the autosave timer is up, wait for "onSettle" and setTimeout 5 seconds to request a save
// if changes were made during that time, clear timeout and wait for onSettle again
// This module needs "chainpad" from app-framework
// It exports "setSaver"
// the "saver" is defined in framework: get the content, use "export.js" to make a blob, push to outer and then API and call back to send "ISAVED"
// It may also export a function when a save is forced to Nextcloud (server crash?)
var config = privateData.integrationConfig;
if (!config.autosave) { return; }
// TODO
// Add timeout to my save lock: if I can't save in a correct amount of time, someone else will do it
var state = {
changed: false,
last: +new Date(),
lastTmp: undefined,
other: false, // save lock
me: false // save lock
};
var saveTo;
var onSettleTo;
var BASE_TIMER = config.autosave * 1000;
var autosaveTimer = BASE_TIMER;
var SETTLE_TO = 3000;
var SAVE_TO = Math.min((BASE_TIMER / 2), 10000);
var requestSave = function () {}; // placeholder
var saved = function () {}; // placeholder;
var save = function (id) {
id = id || Util.uid();
requestSave(id, function (allowed) {
if (!allowed) { return; }
var onError = function (err) {
state.me = false;
// Increase my timer up to 1.5 times the normal timer
if (autosaveTimer < (BASE_TIMER*1.5)) {
autosaveTimer += 1000;
}
if (!err) { return; } // "err" is undefined in case of timeout
// If err while saving, warn others
execCommand('SEND', {
msg: 'IFAILED',
uid: id
}, function (err) {
if (err) { console.error(err); }
});
};
var myTo = setTimeout(onError, SAVE_TO);
saveHandler(function (err) {
clearTimeout(myTo);
if (err) { return void onError(err); }
saved();
});
});
};
var onMessage = function (data) {
if (!data || !data.msg) { return; }
if (data.msg === "ISAVE") {
if (state.me) { return; } // I have the lock: abort
if (state.other && state.other !== data.uid) { return; } // someone else has the lock
// If state.other === data.uid ==> save failed and someone else tries again
if (!state.changed) { return; }
// If !state.other: nobody has the lock, give them
state.other = data.uid;
state.lastTmp = +new Date();
// Add timeout to make sure the save is done
clearTimeout(saveTo);
clearTimeout(onSettleTo);
state.changed = false;
saveTo = setTimeout(function () {
// They weren't able to save in time, try ourselves
var id = state.other;
state.other = false;
save(id);
}, SAVE_TO);
return;
}
if (data.msg === "ISAVED") {
// Save confirmed: update current state
state.last = state.lastTmp || +new Date();
state.other = false;
state.me = false;
// And clear timeout
clearTimeout(saveTo);
clearTimeout(onSettleTo);
// Make sure pending changes will be save at next interval
if (state.changed) { exp.changed(); }
}
if (data.msg === "IFAILED") {
if (state.me) { return; } // I already have the lock
if (state.other !== data.uid) { return; } // Someone else took the lock
state.other = false;
if (!state.changed) { return; }
clearTimeout(saveTo);
clearTimeout(onSettleTo);
save();
}
};
var onEvent = function (obj) {
var cmd = obj.ev;
var data = obj.data;
if (cmd === 'MESSAGE') {
onMessage(data);
return;
}
};
var module = Common.makeUniversal('integration', {
onEvent: onEvent
});
var execCommand = module.execCommand;
// Request a save lock.
// Callback with "true" if allowed to save or "false" if someone else
// is already saving
requestSave = function (id, cb) {
if (state.other || state.me) { return void cb(false); } // save in progress
execCommand('SEND', {
msg: 'ISAVE',
uid: id
}, function (err) {
if (err) { console.error(err); return void cb(false); }
if (state.other || state.me) {
// someone else requested before me
return void cb(false);
}
state.me = true;
state.lastTmp = +new Date();
state.changed = false;
cb(true);
});
};
saved = function () {
state.last = state.lastTmp || +new Date();
state.other = false;
state.me = false;
execCommand('SEND', {
msg: 'ISAVED'
}, function (err) {
if (err) { console.error(err); }
// Make sure pending changes will be save at next interval
if (state.changed) { exp.changed(); }
});
};
var changedTo;
// Wait for SETTLE_TO ms without changes to start the saving process
var addOnSettleTo = function () {
clearTimeout(onSettleTo);
onSettleTo = setTimeout(save, SETTLE_TO);
};
exp.changed = function () {
state.changed = true;
var timeSinceLastSave = +new Date() - state.last; // in ms
var to = autosaveTimer - timeSinceLastSave; // negative if we can save
if (to > 0) { // try again in "to"
if (!changedTo) { changedTo = setTimeout(exp.changed, to+1); }
return;
}
// Clear existing timeouts
clearTimeout(changedTo);
changedTo = undefined;
clearTimeout(onSettleTo);
// If someone is saving, nothing to do
if (state.other || state.me) { return; }
// We need a save: refresh TO
addOnSettleTo();
};
return exp;
};
return module;
});

View file

@ -727,6 +727,7 @@ define([
// Integration
additionalPriv.integration = cfg.integration;
additionalPriv.integrationConfig = cfg.integrationConfig;
additionalPriv.initialState = cfg.initialState instanceof Blob ?
cfg.initialState : undefined;
@ -1817,6 +1818,18 @@ define([
cfg.addRpc(sframeChan, Cryptpad, Utils);
}
sframeChan.on('Q_INTEGRATION_OPENCHANNEL', function (data, cb) {
Cryptpad.universal.execCommand({
type: 'integration',
data: {
cmd: 'INIT',
data: {
channel: data,
secret: secret
}
}
}, cb);
});
sframeChan.on('Q_CURSOR_OPENCHANNEL', function (data, cb) {
Cryptpad.universal.execCommand({
type: 'cursor',
@ -1928,13 +1941,13 @@ define([
var integrationSave = function () {};
if (cfg.integration) {
// TODO
sframeChan.on('Q_INTEGRATION_SAVE', function (obj, cb) {
if (cfg.integrationUtils && cfg.integrationUtils.save) {
cfg.integrationUtils.save(obj, cb);
}
});
integrationSave = function () {
// XXX TODO
sframeChan.event('EV_INTEGRATION_NEEDSAVE');
};
}
@ -2058,6 +2071,7 @@ define([
onError: function (err) {
if (!cfg.integration) { return; }
console.error(err);
// XXX TODO on server crash, try to save to Nextcloud?
if (cfg.integrationUtils && cfg.integrationUtils.reload) {
cfg.integrationUtils.reload();
}

View file

@ -11,6 +11,7 @@ define([
'/common/sframe-common-file.js',
'/common/sframe-common-codemirror.js',
'/common/sframe-common-cursor.js',
'/common/sframe-common-integration.js',
'/common/sframe-common-mailbox.js',
'/common/inner/cache.js',
'/common/inner/common-mediatag.js',
@ -41,6 +42,7 @@ define([
File,
CodeMirror,
Cursor,
Integration,
Mailbox,
Cache,
MT,
@ -131,6 +133,9 @@ define([
// Cursor
funcs.createCursor = callWithCommon(Cursor.create);
// Integration
funcs.createIntegration = callWithCommon(Integration.create);
// Files
funcs.uploadFile = callWithCommon(File.uploadFile);
funcs.createFileManager = callWithCommon(File.create);
@ -391,6 +396,22 @@ define([
});
};
funcs.openIntegrationChannel = function (saveChanges) {
var md = JSON.parse(JSON.stringify(ctx.metadataMgr.getMetadata()));
var channel = md.integration;
if (typeof(channel) !== 'string' || channel.length !== Hash.ephemeralChannelLength) {
channel = Hash.createChannelId(true); // true indicates that it's an ephemeral channel
}
if (md.integration !== channel) {
md.integration = channel;
ctx.metadataMgr.updateMetadata(md);
setTimeout(saveChanges);
}
ctx.sframeChan.query('Q_INTEGRATION_OPENCHANNEL', channel, function (err, obj) {
if (err || (obj && obj.error)) { console.error(err || (obj && obj.error)); }
});
};
// CodeMirror
funcs.initCodeMirrorApp = callWithCommon(CodeMirror.create);

View file

@ -101,6 +101,8 @@
key: key,
application: config.documentType,
document: blob,
ext: config.document.fileType,
autosave: config.autosave || 10
}, function (obj) {
if (obj && obj.error) { reject(obj.error); return console.error(obj.error); }
console.log('OUTER START SUCCESS');
@ -111,6 +113,7 @@
};
var onKeyValidated = function () {
console.warn(config, config.document.blob);
if (config.document.blob) { // This is a reload
blob = config.document.blob;
return start();
@ -137,8 +140,11 @@
};
getSession(onKeyValidated);
chan.on('SAVE', function (data) {
config.events.onSave(data);
chan.on('SAVE', function (data, cb) {
// XXX onSave should "return", "cb" and/or "promise" ?
blob = data;
console.error(blob);
config.events.onSave(data, cb);
});
chan.on('RELOAD', function (data) {
config.document.blob = blob;

View file

@ -110,8 +110,9 @@ define([
});
});
var save = function (data, cb) {
var save = function (obj, cb) {
console.error(obj);
chan.send('SAVE', obj.blob, cb);
};
var reload = function (data, cb) {
chan.send('RELOAD', data);
@ -127,6 +128,10 @@ define([
hash: data.key,
href: href,
initialState: data.document,
config: {
fileType: data.ext,
autosave: data.autosave
},
utils: {
save: save,
reload: reload