feat(storage): office apps history refactor

This commit is contained in:
yflory 2026-06-15 17:57:37 +02:00
parent efabdfe00d
commit f9f5963da8
7 changed files with 207 additions and 168 deletions

View File

@ -210,6 +210,9 @@
flex: 1;
background-color: @history_userBg1;
border: 2px solid @history_userBg1;
display: flex;
align-items: center;
justify-content: center;
&:nth-child(2n) {
background-color: @history_userBg2;
border: 2px solid @history_userBg2;
@ -217,7 +220,7 @@
&.cp-selected {
border: 2px solid @cryptpad_text_col;
position: relative;
svg {
svg:not([data-snapshot]) {
position: absolute;
margin: 0;
left: 50%;
@ -225,6 +228,10 @@
transform: translateX(-50%)
}
}
svg[data-snapshot] {
height: 100%;
margin: 0;
}
}
}
.cp-history-timeline-users {

View File

@ -219,12 +219,16 @@ const addLinkedDocument = (env, channelId, type, data, cb) => {
} else if (type === 'media') {
if (data.length !== 48) { return void cb('EINVAL'); }
content.media ||= [];
if (content.media.includes(data)) { return void cb(); }
if (content.media.includes(data)) {
return void cb(void 0, content);
}
content.media.push(data);
} else if (type === 'channels') {
if (data.length !== 32) { return void cb('EINVAL'); }
content.channels ||= [];
if (content.channels.includes(data)) { return void cb(); }
if (content.channels.includes(data)) {
return void cb(void 0, content);
}
content.channels.push(data);
}
writeLinkedFile(env, path, content, cb);

View File

@ -41,6 +41,9 @@ const factory = (Feedback) => {
channel: channel,
};
} else { // same channel existing tab
setTimeout(() => {
ctx.emit('READY', chan.clients, [client]);
});
return void cb();
}

View File

@ -14,6 +14,73 @@ define([
//var ChainPad = window.ChainPad;
var History = {};
History.loadHistoryData = (cfg) => {
const {
sframeChan, mainRtChannel, hashes, sortedCp, downloadId,
currentCp, nextCp
} = cfg;
return new Promise((resolve, reject) => {
// New CP: use this cp's rtChannel
if (currentCp?.rtChannel) {
// Load all messages from currentCp.rtChannel
sframeChan.query('Q_GET_FULL_HISTORY', {
channel: currentCp.rtChannel,
isDownload: downloadId,
full: true
}, function (err, data) {
if (err) { return void reject(err); }
resolve(data);
});
return;
}
// Old CP or no CP: use mainRtChannel
// No CP and no nextCp hash
if (!currentCp && !nextCp?.hash) {
// Load all messages from mainRtChannel
sframeChan.query('Q_GET_FULL_HISTORY', {
channel: mainRtChannel,
isDownload: downloadId,
full: true
}, function (err, data) {
if (err) { return void reject(err); }
resolve(data);
});
return;
}
// If we have a startHash or an endHash, use
// the GET_HISTORY_RANGE command
let startHash = currentCp?.hash || 'NONE';
let endHash = nextCp?.hash;
if (currentCp?.hash || nextCp?.hash) {
sframeChan.query('Q_GET_HISTORY_RANGE', {
channel: mainRtChannel,
lastKnownHash: endHash,
toHash: startHash,
isDownload: downloadId,
}, function (err, data) {
if (err || !Array.isArray(data.messages)) {
return void reject(err || 'EINVAL');
}
let msgs = data.messages;
if (data.messages[0].serverHash === startHash) {
msgs.shift();
}
resolve(msgs);
});
return;
}
reject('INVALID_CP');
});
};
History.create = function (common, config) {
if (!config.$toolbar) { return void console.error("config.$toolbar is undefined");}
if (History.loading) { return void console.error("History is already being loaded..."); }
@ -26,6 +93,7 @@ define([
throw new Error("Missing config element");
}
const metadataMgr = common.getMetadataMgr();
const ooMessages = {};
@ -64,79 +132,14 @@ define([
let currentCp = hashes[cpId];
let nextCp = hashes[sortedCp[cpIdx + 1]];
// New CP: use this cp's rtChannel
if (currentCp?.rtChannel) {
// Load all messages from currentCp.rtChannel
sframeChan.query('Q_GET_FULL_HISTORY', {
channel: currentCp.rtChannel,
full: true
}, function (err, data) {
if (err) { return void reject(err); }
data.unshift(void 0);
ooMessages[cpId] = data;
resolve(data);
});
return;
}
// Old CP or no CP: use mainRtChannel
// No CP and no nextCp hash
if (!currentCp && !nextCp?.hash) {
// Load all messages from mainRtChannel
sframeChan.query('Q_GET_FULL_HISTORY', {
channel: mainRtChannel,
full: true
}, function (err, data) {
if (err) { return void reject(err); }
data.unshift(void 0);
ooMessages[cpId] = data;
resolve(data);
});
return;
}
// If we have a startHash or an endHash, use
// the GET_HISTORY_RANGE command
/**
* startHash is the first element to load from history
* - 'NONE' means load from the start of the file
* - otherwise load from the given hash
*/
let startHash = currentCp?.hash || 'NONE';
/**
* endHash is the last (most recent) element to load
* - undefined means load until the end of the file
* - if nextCp doesn't exist or is on the new format
* - otherwise load until the given hash
*/
let endHash = nextCp?.hash;
// Old CP or no CP and nextCp hash
if (currentCp?.hash || nextCp?.hash) {
// Load all messages of mainRtChannel from "hash" to nextCp.hash
sframeChan.query('Q_GET_HISTORY_RANGE', {
channel: mainRtChannel,
lastKnownHash: endHash,
toHash: startHash,
}, function (err, data) {
if (err || !Array.isArray(data.messages)) {
return void reject(err || 'EINVAL');
}
let msgs = data.messages;
if (data.messages[0].serverHash === startHash) {
msgs.shift();
}
msgs.unshift(void 0);
ooMessages[cpId] = msgs;
resolve(msgs);
});
return;
}
reject('INVALID_CP');
History.loadHistoryData({
sframeChan,
mainRtChannel, hashes, sortedCp, currentCp, nextCp
}).then(data => {
data.unshift(void 0);
ooMessages[cpId] = data;
resolve(data);
}).catch(reject);
});
};
@ -178,23 +181,38 @@ define([
$timeline.empty();
const msgs = getCpMsgs();
const cp = hashes[getCpId()];
const md = Util.clone(metadataMgr.getMetadata());
const snapshots = md?.snapshots || {};
const els = msgs.map((msg, i) => {
const selected = i === msgIdx;
const selClass = selected ? '.cp-selected' : '';
const content = selected ? Icons.get('chevron-down', {})
: undefined;
let title = `${getCpId()}.${i}`
const s = snapshots[title];
if (msg?.time) {
title += ' - ' + new Date(msg.time).toLocaleString();
} else if (i === 0 && cp?.time) {
title += ' - ' + new Date(cp.time).toLocaleString();
}
let snap;
if (s) {
if (s?.title) { title += `\n${Util.fixHTML(s.title)}`; }
snap = Icons.get('snapshot', {'data-snapshot': '1'});
}
return h('span.cp-history-bar-el'+selClass, {
title,
'data-msg': i
}, content);
}, [content, snap]);
});
$timeline.append(els);
updateNavButtons();
};
@ -404,9 +422,12 @@ define([
onKeyUp = function (e) { e.stopPropagation(); };
$(window).on('keydown', onKeyDown).on('keyup', onKeyUp).focus();
$timeline.on('click', '.cp-history-bar-el', (ev) => {
const target = ev.target;
$timeline.on('click', '.cp-history-bar-el', (ev, el) => {
let target = ev.target;
if (!target) { return; }
if (!target.classList.contains('cp-history-bar-el')) {
target = $(target).closest('.cp-history-bar-el')[0];
}
const attr = target?.attributes?.getNamedItem('data-msg');
const idx = Number(attr?.value || 0);
if (idx > msgIdx) {
@ -424,9 +445,6 @@ define([
});
});
$(snapshot).click(function () {
// XXX
/*
if (cpIndex === -1 && msgIndex === -1) { return void UI.warn(Messages.snapshots_ooPickVersion); }
var input = h('input', {
placeholder: Messages.snapshots_placeholder
});
@ -448,14 +466,14 @@ define([
onClick: function () {
var val = $input.val();
if (!val) { return true; }
msgs = ooMessages[id];
const patch = getCurrentMsg();
config.makeSnapshot(val, function (err) {
if (err) { return; }
$input.val('');
UI.log(Messages.saved);
}, {
hash: getVersion(position),
time: currentTime || patch && patch.time || 0
hash: getCurrentVersion(),
time: patch?.time || +new Date()
});
},
keys: [13],
@ -465,7 +483,6 @@ define([
setTimeout(function () {
$input.focus();
});
*/
});
// Close & restore buttons
@ -494,9 +511,7 @@ define([
msgIdx = msgs.length - 1;
showVersion();
updateTimeline();
});
}).catch(err => { console.error(err); });
};
return History;

View File

@ -144,6 +144,17 @@ define([
return w.editor || w.editorCell;
};
const addOfficeJS = (version) => {
if (typeof(version) === 'number') {
version = `v${version}/`;
}
var s = h('script', {
type:'text/javascript',
src: ApiConfig.httpSafeOrigin + '/common/onlyoffice/dist/'+version+'web-apps/apps/api/documents/api.js?' + APP.urlArgs
});
$('#cp-app-oo-editor').append(s);
};
var setEditable = function (state, force) {
$('#cp-app-oo-editor').find('#cp-app-oo-offline').remove();
/*
@ -623,6 +634,14 @@ define([
if (APP.docEditor) { APP.docEditor.destroyEditor(); } // Kill the old editor
$('iframe[name="frameEditor"]').after(h('div#cp-app-oo-placeholder-a')).remove();
const v = cpData.version || content.version;
if (v && v !== APP.currentOOVersion) {
$('#cp-app-oo-editor').find('script').remove();
addOfficeJS(v);
APP.currentOOVersion = v;
}
ooLoaded = false;
oldLocks = {};
Object.keys(pendingChanges).forEach(function (key) {
@ -811,56 +830,44 @@ define([
var openVersionHash = function (version) {
readOnly = true;
var hashes = content.hashes || {};
var sortedCp = sortCpIndex(hashes);
var s = version.split('.');
var v = parseInt(s[1]);
const hashes = content.hashes || {};
const sortedCp = sortCpIndex(hashes);
const s = version.split('.');
if (s.length !== 2) { return UI.errorLoadingScreen(Messages.error); }
const cpId = Number(s[0]);
const cp = hashes[cpId] || {};
let cpId = Number(s[0]);
if (APP.isDownload) { cpId = sortedCp[sortedCp.length - 1]; }
const currentCp = hashes[cpId] || {};
const cpIdx = sortedCp.indexOf(cpId);
const nextCpId = sortedCp[cpidx + 1];
const nextCp = sortedCp[cpIdx + 1];
var minor = Number(s[1]) + 1;
let minor = Number(s[1]);
if (APP.isDownload) { minor = undefined; }
var toHash = cp.hash || 'NONE';
var fromHash = nextCpId ? hashes[nextCpId].hash : 'NONE';
// XXX XXX HISTORY
// if (cp.file && !cp.hash) {} // new format, full history...
sframeChan.query('Q_GET_HISTORY_RANGE', {
channel: content.channel,
lastKnownHash: fromHash,
toHash: toHash,
isDownload: APP.isDownload
}, function (err, data) {
if (err) { console.error(err); return void UI.errorLoadingScreen(Messages.error); }
if (!Array.isArray(data.messages)) {
console.error('Not an array');
return void UI.errorLoadingScreen(Messages.error);
}
// The first "cp" in history is the empty doc. It doesn't include the first patch
// of the history
var messages = data.messages;
History.loadHistoryData({
sframeChan,
mainRtChannel: content.channel,
hashes: content.hashes,
downloadId: APP.isDownload,
sortedCp, currentCp, nextCp
}).then(messages => {
// Parse messages
messages.forEach(function (obj) {
try { obj.msg = JSON.parse(obj.msg); } catch (e) { console.error(e); }
});
// The version exists if we have results in the "messages" array
// or if we requested a x.0 version
var exists = !Number(s[1]) || messages.length;
var exists = !minor || messages.length;
var vHashEl;
if (!privateData.embed) {
var vTime = (messages[messages.length - 1] || {}).time;
var vTime = (messages[minor - 1] || currentCp)?.time;
var vTimeStr = vTime ? new Date(vTime).toLocaleString()
: 'v' + privateData.ooVersionHash;
var vTxt = Messages._getKey('infobar_versionHash',  [vTimeStr]);
var vTxt = Messages._getKey('infobar_versionHash', [vTimeStr]);
// If we expected patched and we don't have any, it means this part
// of the history has been deleted
@ -876,29 +883,32 @@ define([
if (!exists) { return void UI.removeLoadingScreen(); }
loadLastDocument(cp)
.then(({blob, fileType}) => {
// XXX HISTORY minor.... ?
ooChannel.queue = messages.slice(1, minor+1);
resetData(blob, fileType, cp);
APP.history = true;
loadLastDocument(currentCp)
.then(({blob, fileType}) => {
ooChannel.queue = messages.slice(0, minor);
console.error(ooChannel.queue.slice());
resetData(blob, fileType, currentCp);
UI.removeLoadingScreen();
})
.catch(() => {
if (cp.hash && vHashEl) {
// We requested a checkpoint but we can't find it...
UI.removeLoadingScreen();
})
.catch(() => {
if (cp.hash && vHashEl) {
// We requested a checkpoint but we can't find it...
UI.removeLoadingScreen();
vHashEl.innerText = Messages.oo_deletedVersion;
$(vHashEl).removeClass('alert-warning').addClass('alert-danger');
return;
}
var file = getFileType();
var type = common.getMetadataMgr().getPrivateData().ooType;
if (APP.downloadType) { type = APP.downloadType; }
var blob = loadInitDocument(type, true);
ooChannel.queue = file.doc === 'spreadsheet' ? messages.slice(0, v) : messages.slice(0, v+1);
resetData(blob, file, {});
UI.removeLoadingScreen();
});
vHashEl.innerText = Messages.oo_deletedVersion;
$(vHashEl).removeClass('alert-warning').addClass('alert-danger');
return;
}
var file = getFileType();
var type = common.getMetadataMgr().getPrivateData().ooType;
if (APP.downloadType) { type = APP.downloadType; }
var blob = loadInitDocument(type, true);
ooChannel.queue = messages.slice(0, minor);
resetData(blob, file, {});
UI.removeLoadingScreen();
});
}).catch(err => {
if (err) { console.error(err); return void UI.errorLoadingScreen(Messages.error); }
});
};
@ -1116,6 +1126,8 @@ define([
};
// Update the locks status in onlyoffice
var handleNewLocks = function (o, n) {
if (APP.history) { return; }
var hasNew = false;
// Check if we have at least one new lock
Object.keys(n || {}).some(function (id) {
@ -1156,7 +1168,7 @@ define([
var users = Object.keys(metadataMgr.getMetadata().users);
Object.keys(locks).forEach(function (id) {
var nId = id.slice(0,32);
if (users.indexOf(nId) === -1) {
if (users.indexOf(nId) === -1 || APP.history) {
// Offline locks: support old format
var l = (locks[id] && !locks[id].block) ? getUserLock(id) : [locks[id]];
ooChannel.send({
@ -3004,6 +3016,7 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
APP.onLocal = config.onLocal = function () {
if (initializing) { return; }
if (readOnly) { return; }
if (APP.history) { return; }
// Update metadata
var content = stringifyInner();
@ -3155,11 +3168,8 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
readOnly = true;
var version = (!content.version || content.version === 1) ? 'v1/' :
(content.version <= 3 ? 'v2b/' : OOCurrentVersion.currentVersion + '/');
var s = h('script', {
type:'text/javascript',
src: ApiConfig.httpSafeOrigin + '/common/onlyoffice/dist/'+version+'web-apps/apps/api/documents/api.js?' + APP.urlArgs
});
$('#cp-app-oo-editor').empty().append(h('div#cp-app-oo-placeholder-a')).append(s);
$('#cp-app-oo-editor').empty().append(h('div#cp-app-oo-placeholder-a'));
addOfficeJS(version);
var hashes = content.hashes || {};
var idx = sortCpIndex(hashes);
@ -3283,10 +3293,11 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
ooChannel.queue = [];
ooChannel.ready = false;
// Fill the queue and then load the last CP
//rtChannel.getHistory(function () {
rtChannel.getHistory(function () {
var lastCp = getLastCp();
loadCp(lastCp, true);
//});
});
};
var deleteSnapshot = function (hash) {
@ -3556,14 +3567,6 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
checkLinkedDocs();
}
Object.keys(content.hashes).forEach(id => {
let cpData = content.hashes[id];
if (!cpData.rtChannel) { return; }
delete cpData.index;
delete cpData.hash;
});
APP.onLocal();
APP.startNew = isNew;
var version = OOCurrentVersion.currentVersion + '/';
@ -3681,11 +3684,9 @@ APP.onLocal();
checkCheckpoint();
}
var s = h('script', {
type:'text/javascript',
src: ApiConfig.httpSafeOrigin + '/common/onlyoffice/dist/'+version+'web-apps/apps/api/documents/api.js?' + APP.urlArgs
});
$('#cp-app-oo-editor').append(s);
addOfficeJS(version);
APP.currentOOVersion = content.version || 1;
if (metadataMgr.getPrivateData().burnAfterReading && content && content.channel) {
sframeChan.event('EV_BURN_PAD', content.channel);
@ -3735,6 +3736,7 @@ APP.onLocal();
common.openCursorChannel(APP.onLocal);
cursor = common.createCursor(APP.onLocal);
cursor.onCursorUpdate(function (data) {
if (APP.history) { return; }
// Leaving user
if (data && data.leave && data.id) {
// When a netflux user leaves, remove all their cursors
@ -4018,6 +4020,8 @@ APP.onLocal();
content = json.content;
if (APP.history) { return; }
if (content.saveLock && wasLocked !== content.saveLock) {
// Someone new is creating a checkpoint: fix the sheets ids
fixSheets();

View File

@ -1716,12 +1716,18 @@ define([
// History
sframeChan.on('Q_GET_FULL_HISTORY', function (data, cb) {
var crypto = Crypto.createEncryptor(secret.keys);
let nSecret = secret;
if (data.isDownload && ooDownloadData[data.isDownload]) {
var ooData = ooDownloadData[data.isDownload];
delete ooDownloadData[data.isDownload];
nSecret = Utils.Hash.getSecrets('sheet', ooData.hash, ooData.password);
}
var crypto = Crypto.createEncryptor(nSecret.keys);
Cryptpad.getFullHistory({
debug: data?.debug,
full: data?.full,
channel: data.channel || secret.channel,
validateKey: secret.keys.validateKey
channel: data.channel || nSecret.channel,
validateKey: nSecret.keys.validateKey
}, function (encryptedMsgs) {
var nt = nThen;
var decryptedMsgs = [];

File diff suppressed because one or more lines are too long