From a4e821e4fa6489fa1c33bce98468abb002857178 Mon Sep 17 00:00:00 2001 From: yflory Date: Thu, 26 Jan 2023 18:40:31 +0100 Subject: [PATCH] API: add autosave support --- www/code/inner.js | 2 +- www/common/common-util.js | 2 +- www/common/outer/async-store.js | 8 +- www/common/outer/integration.js | 207 ++++++++++++++++++++++++ www/common/sframe-app-framework.js | 69 +++++++- www/common/sframe-app-outer.js | 1 + www/common/sframe-common-codemirror.js | 2 +- www/common/sframe-common-integration.js | 205 +++++++++++++++++++++++ www/common/sframe-common-outer.js | 16 +- www/common/sframe-common.js | 21 +++ www/cryptpad-api.js | 10 +- www/integration/main.js | 9 +- 12 files changed, 535 insertions(+), 17 deletions(-) create mode 100644 www/common/outer/integration.js create mode 100644 www/common/sframe-common-integration.js diff --git a/www/code/inner.js b/www/code/inner.js index 562e08556..e7b78eca6 100644 --- a/www/code/inner.js +++ b/www/code/inner.js @@ -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; }); diff --git a/www/common/common-util.js b/www/common/common-util.js index 05da8bf93..bbdbc3304 100644 --- a/www/common/common-util.js +++ b/www/common/common-util.js @@ -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); }, diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index 7e040ce0b..f4df396cc 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -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']; diff --git a/www/common/outer/integration.js b/www/common/outer/integration.js new file mode 100644 index 000000000..7dc1c7768 --- /dev/null +++ b/www/common/outer/integration.js @@ -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 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; +}); diff --git a/www/common/sframe-app-framework.js b/www/common/sframe-app-framework.js index f8e3dd0d9..f7847c668 100644 --- a/www/common/sframe-app-framework.js +++ b/www/common/sframe-app-framework.js @@ -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(); }); diff --git a/www/common/sframe-app-outer.js b/www/common/sframe-app-outer.js index 7d74fb836..4f7219356 100644 --- a/www/common/sframe-app-outer.js +++ b/www/common/sframe-app-outer.js @@ -30,6 +30,7 @@ define([ messaging: true, integration: isIntegration, integrationUtils: integration.utils, + integrationConfig: integration.config || {}, initialState: integration.initialState || undefined }); }); diff --git a/www/common/sframe-common-codemirror.js b/www/common/sframe-common-codemirror.js index e245d9cff..990734de8 100644 --- a/www/common/sframe-common-codemirror.js +++ b/www/common/sframe-common-codemirror.js @@ -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) { diff --git a/www/common/sframe-common-integration.js b/www/common/sframe-common-integration.js new file mode 100644 index 000000000..fba90b7cd --- /dev/null +++ b/www/common/sframe-common-integration.js @@ -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; +}); + + diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index 9fa8fd9ac..4196665fc 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -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(); } diff --git a/www/common/sframe-common.js b/www/common/sframe-common.js index f1705d694..6ff6b2858 100644 --- a/www/common/sframe-common.js +++ b/www/common/sframe-common.js @@ -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); diff --git a/www/cryptpad-api.js b/www/cryptpad-api.js index c207a9631..8c05bc261 100644 --- a/www/cryptpad-api.js +++ b/www/cryptpad-api.js @@ -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; diff --git a/www/integration/main.js b/www/integration/main.js index 48daa1b3d..8984b9b56 100644 --- a/www/integration/main.js +++ b/www/integration/main.js @@ -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