cryptpad/www/common/sframe-common-history.js

697 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
define([
'jquery',
'/common/common-interface.js',
'/common/common-util.js',
'/common/hyperscript.js',
'/customize/messages.js',
'/components/nthen/index.js',
//'/components/chainpad-json-validator/json-ot.js',
'/components/chainpad/chainpad.dist.js',
], function ($, UI, Util, h, Messages, nThen, ChainPad /* JsonOT */) {
//var ChainPad = window.ChainPad;
var History = {};
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..."); }
if (History.state) { return void console.error("Already loaded"); }
History.loading = true;
History.state = true;
var $toolbar = config.$toolbar;
var $hist = $toolbar.find('.cp-toolbar-history');
$hist.addClass('cp-history-init');
if (!config.applyVal || !config.setHistory || !config.onLocal || !config.onRemote) {
throw new Error("Missing config element: applyVal, onLocal, onRemote, setHistory");
}
var getStates = function (rt) {
var states = [];
var b = rt.getAuthBlock();
if (b) { states.unshift(b); }
while (b.getParent()) {
b = b.getParent();
states.unshift(b);
}
return states;
};
var createRealtime = function (config) {
return ChainPad.create({
userName: 'history',
validateContent: function (content) {
try {
JSON.parse(content);
return true;
} catch (e) {
console.log('Failed to parse, rejecting patch');
return false;
}
},
initialState: '',
logLevel: config.debug ? 2 : 0,
noPrune: true
});
};
var fillChainPad = function (realtime, messages) {
messages.forEach(function (m) {
realtime.message(m);
});
};
var realtime;
var states = [];
var patchWidth = 0;
var c = 0;//states.length - 1;
var getIndex = function (i) {
return states.length - 1 + i;
};
var getRank = function (idx) {
return idx - states.length + 1;
};
// Get the author or group of author linked to a state
var getAuthor = function (idx, semantic) {
if (semantic === 1 || !config.extractMetadata) {
return states[idx].author;
}
try {
var val = JSON.parse(states[idx].getContent().doc);
var md = config.extractMetadata(val);
var users = Object.keys(md.users || {}).sort();
return users.join();
} catch (e) {
console.error(e);
return states[idx].author;
}
};
var bar = h('span.cp-history-timeline-bar');
var onResize = function () {
var $bar = $(bar);
if (!$bar.width() || !$bar.length) { return; }
var widthPx = patchWidth * $bar.width() / 100;
$hist.removeClass('cp-smallpatch');
$bar.find('.cp-history-snapshot').css('margin-left', "");
var $pos = $hist.find('.cp-history-timeline-pos');
$pos.css('margin-left', "");
if (widthPx < 18) {
$hist.addClass('cp-smallpatch');
$bar.find('.cp-history-snapshot').css('margin-left', (widthPx/2-2)+"px");
$pos.css('margin-left', (widthPx/2-2)+"px");
}
};
// Refresh the timeline UI with the block states
var refreshBar = function (snapshotsOnly) {
var $pos = $hist.find('.cp-history-timeline-pos');
var $bar = $(bar);
var users = {
list: [],
author: '',
el: undefined,
i: 0
};
var user = {
list: [],
author: '',
el: undefined,
i: 0
};
var snapshotsData = {};
var snapshots = [];
if (config.getLastMetadata) {
try {
var md = config.getLastMetadata();
if (md.snapshots) {
snapshotsData = md.snapshots;
snapshots = Object.keys(md.snapshots);
}
} catch (e) { console.error(e); }
}
var max = states.length - 1;
var snapshotsEl = [];
patchWidth = 100 / max;
// Check if we need a new block on the index i for the "obj" type (user or users)
var check = function (obj, author, i) {
if (snapshotsOnly) { return; }
if (obj.author !== author) {
obj.author = author;
if (obj.el) {
$(obj.el).css('width', (100*(i - obj.i)/max)+'%');
}
obj.el = h('span.cp-history-bar-el');
obj.list.push(obj.el);
obj.i = i;
}
};
var hash;
for (var i = 1; i < states.length; i++) {
hash = states[i].serverHash;
if (snapshots.indexOf(hash) !== -1) {
snapshotsEl.push(h('div.cp-history-snapshot', {
style: 'width:'+patchWidth+'%;left:'+(patchWidth * (i-1))+'%;',
title: snapshotsData[hash].title
}, h('i.fa.fa-camera')));
}
if (config.drive) {
// Display only one bar, split by patch
check(user, i, i);
} else {
// Display two bars, split by author(s)
check(user, getAuthor(i, 1), i);
check(users, getAuthor(i, 2), i);
}
}
if (snapshotsOnly) {
// We only want to redraw the snapshots
$bar.find('.cp-history-snapshots').html('').append([
$pos,
snapshotsEl
]);
} else {
$(user.el).css('width', (100*(max + 1 - user.i)/max)+'%');
if (!config.drive) {
$(users.el).css('width', (100*(max + 1 - users.i)/max)+'%');
}
$bar.html('').append([
h('span.cp-history-timeline-users', users.list),
h('span.cp-history-timeline-user', user.list),
h('div.cp-history-snapshots', [
$pos[0],
snapshotsEl
]),
]);
}
onResize();
};
var allMessages = [];
var lastKnownHash;
var isComplete = false;
var loadMoreHistory = function (config, common, cb) {
if (isComplete) { return void cb ('EFULL'); }
var realtime = createRealtime(config);
var sframeChan = common.getSframeChannel();
sframeChan.query('Q_GET_HISTORY_RANGE', {
lastKnownHash: lastKnownHash,
sharedFolder: config.sharedFolder
}, function (err, data) {
if (err) { return void console.error(err); }
if (!Array.isArray(data.messages)) { return void console.error('Not an array!'); }
lastKnownHash = data.lastKnownHash;
isComplete = data.isFull;
var messages = (data.messages || []).map(function (obj) {
return obj;
});
// We're supposed to receive 2 checkpoints. If the result is only ONE message
// and this message is a checkpoint, it means it's the last message of the history
// (and this is a trimmed history)
if (messages.length === 1) {
var parsed = JSON.parse(messages[0].msg);
if (parsed[0] === 4) {
isComplete = true;
}
}
if (config.debug) { console.log(data.messages); }
Array.prototype.unshift.apply(allMessages, messages); // Destructive concat
fillChainPad(realtime, allMessages);
cb (null, realtime, data.isFull);
});
};
// config.setHistory(bool, bool)
// - bool1: history value
// - bool2: reset old content?
var render = function (val) {
if (typeof val === "undefined") { return; }
try {
config.applyVal(val);
} catch (e) {
// Probably a parse error
console.error(e);
}
};
var onClose = function () {
config.setHistory(false, true);
};
var onRevert = function () {
// Before we can restore the current version, we need to update metadataMgr
// so that it will uses the snapshots from the realtime version!
// Restoring the snapshots to their old version would go against the
// goal of having snapshots
if (config.getLastMetadata) {
var metadataMgr = common.getMetadataMgr();
var lastMd = config.getLastMetadata() || {};
var _snapshots = lastMd.snapshots;
var _users = lastMd.users;
var md = Util.clone(metadataMgr.getMetadata());
md.snapshots = _snapshots;
md.users = _users;
metadataMgr.updateMetadata(md);
}
// And now we can properly restore the content
var closed = config.setHistory(false, false);
if (!closed) {
return void UI.alert(Messages.history_cantRestore);
}
config.onLocal();
config.onRemote();
return true;
};
config.setHistory(true);
var $bottom = $toolbar.find('.cp-toolbar-bottom');
var $cke = $toolbar.find('.cke_toolbox_main');
$hist.html('').css('display', 'flex');
$bottom.hide();
$cke.hide();
UI.spinner($hist).get().show();
var update = function (newRt) {
realtime = newRt;
if (!realtime) { return []; }
states = getStates(realtime);
refreshBar();
return states;
};
var $loadMore, $time, get;
// Get the content of the selected version, and change the version number
var loading = false;
var loadMore = function (cb) {
if (loading) { return; }
loading = true;
$loadMore.find('.fa-ellipsis-h').hide();
$loadMore.find('.fa-refresh').show();
loadMoreHistory(config, common, function (err, newRt, isFull) {
if (err === 'EFULL') {
$loadMore.off('click').hide();
get(c);
return;
}
loading = false;
if (err) { return void console.error(err); }
update(newRt);
$loadMore.find('.fa-ellipsis-h').show();
$loadMore.find('.fa-refresh').hide();
get(c);
if (isFull) {
$loadMore.off('click').hide();
}
if (cb) { cb(); }
});
};
// semantic === 1 : group by user
// semantic === 2 : group by "group of users"
get = function (i, blockOnly, semantic) {
i = parseInt(i);
if (isNaN(i)) { return; }
if (i > 0) { i = 0; }
if (i < -(states.length - 2)) { i = -(states.length - 2); }
var idx = getIndex(i);
if (semantic && i !== c) {
// If semantic is true, jump to the next patch from a different netflux ID
var author = getAuthor(idx, semantic);
var forward = i > c;
for (var j = idx; (j > 0 && j < states.length ); (forward ? j++ : j--)) {
if (author !== getAuthor(j, semantic)) {
break;
}
idx = j;
i = getRank(idx);
}
}
if (i <= -(states.length - 11)) {
loadMore();
}
if (blockOnly) { return states[idx]; }
var val = states[idx].getContent().doc;
c = i;
$hist.find('.cp-toolbar-history-next, .cp-toolbar-history-previous')
.prop('disabled', '');
if (c === -(states.length-1)) {
$hist.find('.cp-toolbar-history-previous').prop('disabled', 'disabled');
}
if (c === 0) {
$hist.find('.cp-toolbar-history-next').prop('disabled', 'disabled');
}
var $pos = $hist.find('.cp-history-timeline-pos');
var p = 100 * (1 - (-(c - 1) / (states.length-1)));
$pos.css('left', p+'%');
$pos.css('width', patchWidth+'%');
// Display the version when the full history is loaded
// Note: the first version is always empty and probably can't be displayed, so
// we can consider we have only states.length - 1 versions
var time = states[idx].time;
if (time) {
$time.text(new Date(time).toLocaleString());
} else { $time.text(''); }
if (config.debug) {
console.log(states[idx]);
var ops = states[idx] && states[idx].getPatch() && states[idx].getPatch().operations;
if (Array.isArray(ops)) {
ops.forEach(function (op) { console.log(op); });
}
}
return val || '';
};
/*
var getNext = function (step) {
return typeof step === "number" ? get(c + step) : get(c + 1);
};
var getPrevious = function (step) {
return typeof step === "number" ? get(c - step) : get(c - 1);
};
*/
var makeSnapshot = function (title, $input) {
var idx = getIndex(c);
if (!config.getLastMetadata || !config.setLastMetadata) { return; }
try {
var block = states[idx];
var hash = block.serverHash;
var md = config.getLastMetadata();
md.snapshots = md.snapshots || {};
if (md.snapshots[hash]) { return; }
md.snapshots[hash] = {
title: title,
time: block.time ? (+new Date(block.time)) : +new Date()
};
var sent = config.setLastMetadata(md);
if (!sent) { return void UI.alert(Messages.snapshots_cantMake); }
$input.val('');
refreshBar();
} catch (e) {
console.error(e);
}
};
// Create the history toolbar
var display = function () {
$hist.html('');
$hist.removeClass('cp-history-init');
var fastPrev = h('button.cp-toolbar-history-previous', { title: Messages.history_fastPrev }, [
h('i.fa.fa-step-backward'),
h('i.fa.fa-users')
]);
var userPrev = h('button.cp-toolbar-history-previous', { title: Messages.history_userPrev }, [
h('i.fa.fa-step-backward'),
h('i.fa.fa-user')
]);
var prev = h('button.cp-toolbar-history-previous', { title: Messages.history_prev }, [
h('i.fa.fa-step-backward')
]);
var fastNext = h('button.cp-toolbar-history-next', { title: Messages.history_fastNext }, [
h('i.fa.fa-users'),
h('i.fa.fa-step-forward'),
]);
var userNext = h('button.cp-toolbar-history-next', { title: Messages.history_userNext }, [
h('i.fa.fa-user'),
h('i.fa.fa-step-forward'),
]);
var next = h('button.cp-toolbar-history-next', { title: Messages.history_next }, [
h('i.fa.fa-step-forward')
]);
if (config.drive) {
fastNext = h('button.cp-toolbar-history-next', { title: Messages.history_next }, [
h('i.fa.fa-fast-forward'),
]);
fastPrev = h('button.cp-toolbar-history-previous', {title: Messages.history_prev}, [
h('i.fa.fa-fast-backward'),
]);
}
var $fastPrev = $(fastPrev);
var $userPrev = $(userPrev);
var $prev = $(prev);
var $fastNext = $(fastNext);
var $userNext = $(userNext);
var $next = $(next);
var _loadMore = h('button.cp-toolbar-history-loadmore', { title: Messages.history_loadMore }, [
h('i.fa.fa-ellipsis-h'),
h('i.fa.fa-refresh.fa-spin.fa-3x.fa-fw', { style: 'display: none;' })
]);
var pos = h('span.cp-history-timeline-pos.fa.fa-caret-down');
var time = h('div.cp-history-timeline-time');
$time = $(time);
var timeline = h('div.cp-toolbar-history-timeline', [
h('div.cp-history-timeline-line', [
h('span.cp-history-timeline-legend', [
h('i.fa.fa-users'),
h('i.fa.fa-user')
]),
h('span.cp-history-timeline-loadmore', _loadMore),
h('span.cp-history-timeline-container', [
bar
])
]),
h('div.cp-history-timeline-actions', [
h('span.cp-history-timeline-prev', [
fastPrev,
config.drive ? undefined : userPrev,
prev,
]),
time,
h('span.cp-history-timeline-next', [
next,
config.drive ? undefined : userNext,
fastNext
])
])
]);
var snapshot = h('button', {
title: Messages.snapshots_new,
}, [
h('i.fa.fa-camera')
]);
var share = h('button', { title: Messages.history_shareTitle }, [
h('i.fa.fa-shhare-alt'),
h('span', Messages.shareButton)
]);
var restoreTitle = config.drive ? Messages.history_restoreDriveTitle
: Messages.history_restoreTitle;
var restore = h('button', {
title: restoreTitle,
}, [
h('i.fa.fa-check'),
h('span', Messages.history_restore)
]);
var close = h('button', { title: Messages.history_closeTitle }, [
h('i.fa.fa-times'),
h('span', Messages.history_close)
]);
var actions = h('div.cp-toolbar-history-actions', [
h('span.cp-history-actions-first', [
snapshot,
share
]),
h('span.cp-history-actions-last', [
restore,
close
])
]);
if (History.readOnly) {
snapshot.disabled = true;
restore.disabled = true;
}
if (config.drive) {
$hist.addClass('cp-history-drive');
$(snapshot).hide();
$(share).hide();
}
$hist.append([timeline, actions]);
onResize();
$(window).on('resize', onResize);
var $bar = $(bar);
$bar.find('.cp-history-snapshots').append(pos);
$bar.click(function (e) {
e.stopPropagation();
var $t = $(e.target);
if ($t.closest('.cp-history-snapshot').length) {
$t = $t.closest('.cp-history-snapshot');
}
var isEl = $t.is('.cp-history-snapshot');
if (!$t.is('.cp-history-snapshots') && !isEl) { return; }
var x = e.offsetX;
if (isEl) {
x += $t.position().left;
}
var p = x / $bar.width();
var v = 1-Math.ceil((states.length - 1) * (1 - p));
render(get(v));
});
$loadMore = $(_loadMore).click(function () {
loadMore(function () {
get(c);
});
});
var onKeyDown, onKeyUp;
var closeUI = function () {
History.state = false;
$hist.hide();
$bottom.show();
$cke.show();
$(window).trigger('resize');
$(window).off('keydown', onKeyDown);
$(window).off('keyup', onKeyUp);
};
// Version buttons
$prev.click(function () { render(get(c - 1)); });
$next.click(function () { render(get(c + 1)); });
if (config.drive) {
$fastPrev.click(function () { render(get(c - 10)); });
$fastNext.click(function () { render(get(c + 10)); });
$userPrev.click(function () { render(get(c - 10)); });
$userNext.click(function () { render(get(c + 10)); });
} else {
$userPrev.click(function () { render(get(c - 1, false, 1)); });
$userNext.click(function () { render(get(c + 1, false, 1)); });
$fastPrev.click(function () { render(get(c - 1, false, 2)); });
$fastNext.click(function () { render(get(c + 1, false, 2)); });
}
onKeyDown = function (e) {
var p = function () { e.preventDefault(); };
if (e.which === 39) { p(); return $next.click(); } // Right
if (e.which === 37) { p(); return $prev.click(); } // Left
if (e.which === 38) { p(); return $userNext.click(); } // Up
if (e.which === 40) { p(); return $userPrev.click(); } // Down
if (e.which === 33) { p(); return $fastNext.click(); } // PageUp
if (e.which === 34) { p(); return $fastPrev.click(); } // PageUp
if (e.which === 27) { p(); $(close).click(); }
};
onKeyUp = function (e) { e.stopPropagation(); };
$(window).on('keydown', onKeyDown).on('keyup', onKeyUp).focus();
// Snapshots
$(snapshot).click(function () {
var input = h('input', {
placeholder: Messages.snapshots_placeholder
});
var $input = $(input);
var content = h('div', [
h('h5', Messages.snapshots_new),
input
]);
var buttons = [{
className: 'cancel',
name: Messages.filePicker_close,
onClick: function () {},
keys: [27],
}, {
className: 'primary',
iconClass: '.fa.fa-camera',
name: Messages.snapshots_new,
onClick: function () {
var val = $input.val();
if (!val) { return true; }
makeSnapshot(val, $input);
},
keys: [],
}];
UI.openCustomModal(UI.dialog.customModal(content, {buttons: buttons }));
setTimeout(function () {
$input.focus();
});
});
// Share
$(share).click(function () {
var block = get(c, true);
common.getSframeChannel().event('EV_SHARE_OPEN', {
versionHash: block.serverHash,
//title: title
});
});
// Close & restore buttons
$(close).click(function () {
states = [];
onClose();
closeUI();
UI.clearTooltipsDelay();
});
$(restore).click(function () {
var restorePrompt = config.drive ? Messages.history_restoreDrivePrompt
: Messages.history_restorePrompt;
UI.confirm(restorePrompt, function (yes) {
if (!yes) { return; }
var done = onRevert();
if (done) {
closeUI();
var restoreDone = config.drive ? Messages.history_restoreDriveDone
: Messages.history_restoreDone;
UI.log(restoreDone);
}
});
});
// Display the latest content
render(get(c));
$(window).trigger('resize');
};
if (config.onOpen) {
config.onOpen();
}
// Load all the history messages into a new chainpad object
loadMoreHistory(config, common, function (err, newRt, isFull) {
History.readOnly = common.getMetadataMgr().getPrivateData().readOnly;
History.loading = false;
if (err) { throw new Error(err); }
update(newRt);
display();
if (isFull) {
$loadMore.off('click').hide();
}
});
};
return History;
});