etherpad-lite/src/static/js/revisioncache.js

574 lines
21 KiB
JavaScript
Raw Normal View History

/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// revision info is a skip list whos entries represent a particular revision
// of the document. These revisions are connected together by various
// changesets, or deltas, between any two revisions.
2013-12-08 20:16:53 +02:00
//require('./jquery.class');
$.Class("Changeset",
{//statics
},
{//instance
init: function (deltarev, deltatime, value) {
this.deltarev = deltarev;
this.deltatime = deltatime;
this.value = value;
},
getValue: function () {
return this.value;
},
}
);
$.Class("DirectionalIterator",
{//statics
},
{//instance
init: function (list, direction) {
self.list = list;
self.direction = direction;
self.current = self.direction ? self.list.length - 1 : 0;
},
haveNext: function () {
if ((self.direction && self.current > 0)
|| (!self.direction && self.current < self.list.length))
return true;
return false;
},
next: function () {
if (self.direction && self.current > 0)
return self.list[self.current--];
if (!self.direction && self.current < self.list.length)
return self.list[self.current++];
return undefined;
}
}
);
2013-12-10 19:00:17 +02:00
/**
* Revision class. Represents a specific revision. Each instance has three
* possible edges in each direction. Each edge is essentially a Changeset.
* We store three edges at different granularities, to make skipping fast.
* e.g. to go from r1 to r251, you start at r1, use the big edge to go to
* r100, use the big edge again to go to r200, use the next 5 medium edges to
* go from r200 to r210, etc. until reaching r250, follow the next small edge
* to get to 251. A total of 8 edges are traversed (a.k.a. applied),
* making this significantly cheaper than applying all 250 changesets from r1
* to r251.
*/
$.Class("Revision2",
{//statics
// we rely on the fact that granularities are always traversed biggest to
// smallest. Changing this will break lots of stuff.
granularities: {big: 100, medium: 10, small: 1}
},
{//instance
/**
* Create a new revision for the specified revision number.
* @constructor
* @param {number} revnum - The revision number this object represents.
*/
init: function (revnum) {
this.revnum = revnum;
// next/previous edges, granularityed as big, medium and small
this.next = {};
this.previous = {};
for (var granularity in this.granularties) {
this.next[granularity] = null;
this.previous[granularity] = null;
2013-12-10 19:05:49 +02:00
};
2013-12-10 19:00:17 +02:00
},
addChangeset: function (target, changset, timedelta) {
if (this.revnum == target.revnum)
return;
var delta_revnum = target.revnum - this.revnum;
// select the right edge set:
2013-12-10 19:05:49 +02:00
var edge = delta_revnum < 0 ? this.previous : this.next;
2013-12-10 19:00:17 +02:00
// find the correct granularity and add an edge (changeset) for that granularity
2013-12-10 19:05:49 +02:00
for (var granularity in this.granularities) {
2013-12-10 19:00:17 +02:00
if (Math.abs(delta_revnum) == this.granularities[granularity]) {
//TODO: should we check whether the edge exists?
//TODO: modify changeset to store the REVISION, not the revnum.
edge[granularity] = new Changeset(target, timedelta, changeset);
return edge[granularity];
2013-12-10 19:05:49 +02:00
};
};
2013-12-10 19:00:17 +02:00
// our delta_revnum isn't one of the granularities. Something is wrong
//TODO: handle this case?
return null;
},
findPath: function (target) {
//TODO: currently assuming only forward movements
var path = [];
var delta_revnum = target.revnum - this.revnum;
2013-12-10 19:05:49 +02:00
var edge = delta_revnum < 0 ? this.previous : this.next;
2013-12-10 19:00:17 +02:00
var current = this;
while (current.lt(target)) {
for (var granularity in this.granularities) {
if (! Math.abs(current.revnum - target.revnum) >= this.granularities[granularity]) {
// the delta is larger than the granularity, let's use the granularity
//TODO: what happens if we DON'T have the edge?
// in theory we need to fetch it (and this is certainly the case for playback
// at granularity = 1). However, when skipping, we might try to find the NEXT
// Revision (which is not linked by the graph to current) and request revisions
// from current to that Revision (at the largest possible granularity)
var e = edge[granularity];
path.push(e);
current = e.target_revision;
2013-12-12 12:56:21 +02:00
break;
2013-12-10 19:05:49 +02:00
};
};
2013-12-12 12:56:21 +02:00
//TODO: none of the granularities matched. WTF? This probably means that there is NO useful
//edge in the direction of traversal. Is this a termination condition?
2013-12-10 19:05:49 +02:00
};
2013-12-10 19:00:17 +02:00
return path;
}
}
);
$.Class("Revision",
{//statics
},
{//instance
init: function (revnum) {
this.revnum = revnum;
this.changesets = [];
},
addChangeset: function (destindex, changeset, timedelta) {
this.changesets.push(new Changeset(destindex - this.revnum, timedelta, changeset));
this.changesets.sort(function (a, b) {
return (b.deltarev - a.deltarev);
});
},
lt: function (other, is_reverse) {
if (is_reverse)
return this.gt(other);
return this.revnum < other.revnum;
},
gt: function (other, is_reverse) {
if (is_reverse)
return this.lt(other);
return this.revnum > other.revnum;
}
}
);
$.Class("RevisionCache",
{
},
{//instance
2013-12-10 17:57:16 +02:00
/**
* Create a new RevisionCache.
* @constructor
* @param {TimesliderClient} connection - The connection to be used for loading changesets.
* @param {number} head_revnum - The current head revision number. TODO: we can probably do away with this now.
*/
init: function (connection, head_revnum) {
this.connection = connection;
this.loader = new ChangesetLoader(connection);
this.revisions = {};
this.head_revnum = head_revnum || 0;
},
getRevision: function (revnum) {
if (revnum in this.revisions)
return this.revisions[revnum];
this.revisions[revnum] = new Revision(revnum);
this.head_revnum = Math.max(this.head_revnum, revnum);
return this.revisions[revnum];
},
findPath: function (from, to) {
var current_rev = this.getRevision(from);
var to_rev = this.getRevision(to);
var is_reverse = !(from < to);
2013-12-10 17:57:16 +02:00
var res = {
from: current_rev,
current: current_rev,
is_complete: false,
changesets: [],
};
if (from == to) {
//TODO: implement short-circuit
2013-12-10 17:57:16 +02:00
return res;
}
2013-12-10 17:57:16 +02:00
if (!res.current.changesets.length) {
// You cannot build a path if the starting revision hasn't
// got any changesets
//TODO: implement short-circuit
2013-12-10 17:57:16 +02:00
return res;
}
2013-12-10 17:57:16 +02:00
while (res.current.lt(to_rev, is_reverse)) {
var changeset_iterator = new DirectionalIterator(res.current.changesets, is_reverse);
2013-12-05 19:05:08 +02:00
while (changeset_iterator.haveNext()) {
var current_changeset = changeset_iterator.next();
// we might get stuck on a particular revision if only a
// partial path is available.
2013-12-10 17:57:16 +02:00
old_rev = res.current;
2013-12-05 19:05:08 +02:00
// the next (first) changeset in the current revision has a delta
// in the opposite direction to that in which we are trying to
// move, and so cannot help us. Because the changeset list is
// sorted, we can just stop here.
if (current_changeset.deltarev < 0) {
// When can this happen?
stop = true;
}
// the current revision has a changeset which helps us get to a revision
// which is closer to our target, so we should push that changeset to
// the list and move to that new revision to continue building a path
2013-12-10 17:57:16 +02:00
var delta_rev = this.getRevision(res.current.revnum + current_changeset.deltarev);
2013-12-08 20:16:53 +02:00
if (delta_rev.lt(to_rev, is_reverse)) {
2013-12-10 17:57:16 +02:00
res.changesets.push(current_changeset);
res.current = delta_rev;
2013-12-05 19:05:08 +02:00
break;
}
}
2013-12-10 17:57:16 +02:00
if (stop || res.current == old_rev)
2013-12-05 19:05:08 +02:00
break;
}
2013-12-10 17:57:16 +02:00
res.is_complete = res.current == to_rev;
return res;
2013-12-05 19:05:08 +02:00
},
2013-12-08 20:16:53 +02:00
addChangeset: function (from, to, value, reverseValue, timedelta) {
var from_rev = this.getRevision(from);
2013-12-05 19:05:08 +02:00
var to_rev = this.getRevision(to);
2013-12-08 20:16:53 +02:00
from_rev.addChangeset(to, value, timedelta);
to_rev.addChangeset(from, reverseValue, -timedelta);
2013-12-10 17:57:16 +02:00
},
/**
* Iterate over the list of changesets required to go from one revision to another.
* @param {number} from - The starting revision.
* @param {number} to - The end revision.
* @param {function} callback - The function to apply to each changeset.
*/
iterChangesets: function (from, to, callback) {
// first try to build a path from the cache:
var path = this.findPath(from, to);
if (!path.is_complete) {
// TODO: request load of any other changesets.
// before we start iterating over existing
// in the hope that some of them will be
// fulfilled soon.bt
}
// we have a partial path
console.log(from, to, path.current.revnum);
// TODO: loop over existing changesets and apply
2013-12-08 20:16:53 +02:00
}
}
);
2013-12-05 19:05:08 +02:00
2013-12-08 20:16:53 +02:00
$.Class("Thread",
{//statics
},
{//instance
2013-12-08 20:22:14 +02:00
init: function (interval) {
this._is_running = false;
this._is_stopping = false;
2013-12-08 20:16:53 +02:00
this._interval_id = null;
2013-12-08 20:22:14 +02:00
this._interval = interval ? interval : 1000;
2013-12-08 20:16:53 +02:00
},
_run: function () {
console.log("[thread] tick");
},
// start the run loop
start: function () {
var _this = this;
console.log("[thread] starting")
var wrapper = function () {
2013-12-08 20:22:14 +02:00
if (_this._is_running && _this._is_stopping) {
2013-12-08 20:16:53 +02:00
console.log("[thread] shutting down")
clearInterval(_this._interval_id);
2013-12-08 20:22:14 +02:00
_this._is_running = false;
2013-12-08 20:16:53 +02:00
return;
}
_this._run.apply(_this);
};
2013-12-08 20:22:14 +02:00
this._is_running = true;
2013-12-09 15:58:17 +02:00
this._is_stopping = false;
this._interval_id = setInterval(wrapper, this._interval);
2013-12-08 20:16:53 +02:00
},
// stop the run loop
stop: function () {
2013-12-08 20:22:14 +02:00
this._is_stopping = true;
2013-12-08 20:16:53 +02:00
console.log("[thread] request stop")
// TODO: consider finding a way to make this block
// or alternatively, having a callback which is called
// when the thread stops
}
}
);
2013-12-05 19:05:08 +02:00
2013-12-08 20:16:53 +02:00
$.Class("ChangesetRequest",
{//statics
},
{//instance
2013-12-09 15:58:17 +02:00
init: function (start, granularity, callback) {
2013-12-08 20:16:53 +02:00
this.start = start;
2013-12-09 15:58:17 +02:00
this.granularity = granularity;
this.request_id = (this.start << 16) + granularity;
this.fulfill_callback = callback;
},
getRequestID: function () {
return this.request_id;
},
fulfill: function (data) {
console.log("[changesetrequest] Fulfilling request %d", this.getRequestID());
if (this.fulfill_callback)
this.fulfill_callback(data);
2013-12-08 20:16:53 +02:00
}
2013-12-09 15:58:17 +02:00
2013-12-08 20:16:53 +02:00
}
);
2013-12-08 20:16:53 +02:00
Thread("ChangesetLoader",
{//statics
},
{//instance
2013-12-09 15:58:17 +02:00
/**
* Create a new ChangesetLoader.
* @constructor
2013-12-10 17:57:16 +02:00
* @param {TimesliderClient} connection - a TimesliderClient object to be used
2013-12-09 15:58:17 +02:00
* for communication with the server.
*/
2013-12-10 17:57:16 +02:00
init: function (connection) {
2013-12-08 20:22:14 +02:00
this._super(100);
2013-12-10 17:57:16 +02:00
this.connection = connection;
2013-12-09 15:58:17 +02:00
this.queues = {
small: [],
medium: [],
large: [],
}
this.pending = {};
var _this = this;
2013-12-10 17:57:16 +02:00
this.connection.on("CHANGESET_REQ", function () {
2013-12-09 15:58:17 +02:00
_this.on_response.apply(_this, arguments);
});
2013-12-08 20:16:53 +02:00
},
2013-12-09 15:58:17 +02:00
/**
* Enqueue a request for changesets. The changesets will be retrieved
* asynchronously.
* @param {number} start - The revision from which to start.
* @param {number} granularity - The granularity of the changesets. If this
* is 1, the response will include changesets which
* can be applied to go from revision r to r+1.
* If 10 is specified, the resulting changesets will
* be 'condensed', so that each changeset will go from
* r to r+10.
* If any other number is specified, that granularity will
* apply.
*
* TODO: there is currently no 'END' revision implemented
* in the server. The 'END' calculated at the server is:
* start + (100 * granularity)
* We should probably fix this so that you can specify
* exact ranges. Right now, the minimum number of
* changesets/revisions you can retrieve is 100, which
* feels broken.
* @param {function} callback - A callback which will be triggered when the request has
2013-12-10 17:57:16 +02:00
* been fulfilled. The context of the callback will be the
* ChangesetRequest object, so you can check what you actually
* asked for.
2013-12-09 15:58:17 +02:00
*/
enqueue: function (start, granularity, callback) {
2013-12-08 20:16:53 +02:00
//TODO: check cache to see if we really need to fetch this
// maybe even to splices if we just need a smaller range
// in the middle
var queue = null;
2013-12-09 15:58:17 +02:00
if (granularity == 1)
queue = this.queues.small;
else if (granularity == 10)
queue = this.queues.medium;
2013-12-08 20:16:53 +02:00
else
2013-12-09 15:58:17 +02:00
queue = this.queues.large;
2013-12-09 15:58:17 +02:00
queue.push(new ChangesetRequest(start, granularity, callback));
2013-12-08 20:16:53 +02:00
},
_run: function () {
console.log("[changesetloader] tick");
//TODO: pop an item from the queue and perform a request.
2013-12-09 15:58:17 +02:00
for (q in this.queues) {
var queue = this.queues[q];
if (queue.length > 0) {
// TODO: pop and handle
var request = queue.pop();
//TODO: test AGAIN to make sure that it hasn't been retrieved and cached by
//a previous request. This should handle the case when two requests for the
//same changesets are enqueued (which would be fine, as at enqueue time, we
//only check the cache of AVAILABLE changesets, not the pending requests),
//the first one is fulfilled, and then we pop the second one, and don't
//need to perform a server request. Note: it might be worth changing enqueue
//to check the pending requests queue to avoid this situation entirely.
var _this = this;
2013-12-10 17:57:16 +02:00
this.connection.sendMessage("CHANGESET_REQ", {
2013-12-09 15:58:17 +02:00
start: request.start,
granularity: request.granularity,
requestID: request.getRequestID(),
}, function () {
_this.pending[request.getRequestID()] = request;
});
};
};
//TODO: this stop is just for debugging!!!!
//FIXME: remove when done testing
this.stop();
},
on_response: function (data) {
console.log("on_response: ", data)
if (!data.requestID in this.pending) {
console.log("[changesetloader] WTF? changeset not pending: ", data.requestID);
return;
}
2013-12-09 15:58:17 +02:00
// pop it from the pending list:
var request = this.pending[data.requestID];
delete this.pending[data.requestID];
//fulfill the request
request.fulfill(data);
},
}
);
2013-12-10 17:57:16 +02:00
var libchangeset = require("./Changeset");
var AttribPool = require("./AttributePool");
var domline = require("./domline").domline;
var linestylefilter = require("./linestylefilter").linestylefilter;
$.Class("PadClient",
{//static
},
{//instance
/**
* Create a PadClient.
* @constructor
* @param {number} revision - The current revision of the pad.
* @param {datetime} timestamp - The timestamp of the current revision.
* @param {string} atext - The attributed text.
* @param {string} attribs - The attributes string.
* @param {object} apool - The attribute pool.
*/
init: function (revision, timestamp, atext, attribs, apool) {
this.revision = revision;
this.timestamp = timestamp;
this.alines = libchangeset.splitAttributionLines(attribs, atext);
this.apool = (new AttribPool()).fromJsonable(apool);
this.lines = libchangeset.splitTextLines(atext);
//TODO: this is a kludge! we should receive the padcontent as an
//injected dependency
this.divs = [];
this.padcontent = $("#padcontent");
for (var i in this.lines) {
var div = this._getDivForLine(this.lines[i], this.alines[i]);
this.divs.push(div);
this.padcontent.append(div);
};
//TODO: monkey patch divs.splice to use our custom splice function
this.divs.original_splice = this.divs.splice;
this.divs.splice = this._spliceDivs;
},
applyChangeset: function (changeset) {
//TODO: changeset should be a Changeset object
//
// must mutate attribution lines before text lines
libchangeset.mutateAttributionLines(changeset, this.alines, this.apool);
// Looks like this function can take a regular array of strings
2013-12-10 19:00:17 +02:00
libchangeset.mutateTextLines(changeset, /* padcontents */ /*this.lines */ this.divs);
2013-12-10 17:57:16 +02:00
//TODO: get authors (and set in UI)
},
_getDivForLine: function (text, atext) {
var dominfo = domline.createDomLine(text != '\n', true);
// Here begins the magic invocation:
linestylefilter.populateDomLine(text, atext, this.apool, dominfo);
dominfo.prepareForAdd();
var div = $("<div class='" + dominfo.node.className +
"' id='" + Math.random() + "'>" +
dominfo.node.innerHTML + "</div>");
return div;
},
/**
* we need a customized splice function for our divs array, because we
* need to be able to:
* - remove elements from the DOM when they are spliced out
* - create a new div for line elements and add them to the array
* instead of the raw line
* - add the new divs to the DOM
* this function is fully compliant with the Array.prototype.splice
* spec, as we're monkey-patching it on to the divs array.
* @param {number} index - Index at which to start changing the array.
* @param {number} howMany - An integer indicating the number of old array elements to remove.
* @param {array} elements - The elements to add to the array. In our case, these are lines.
*/
_spliceDivs: function (index, howMany, elements) {
// remove howMany divs starting from index. We need to remove them from
// the DOM.
for (var i = index; i < howMany && i < this.divs.length; i++)
this.divs[i].remove()
// generate divs for the new elements:
var newdivs = [];
for (i in elements)
newdivs.push(this._getDivForLine(elements[i], this.alines[index + i]));
// if we are splicing at the beginning of the array, we need to prepend
// to the padcontent DOM element
if (!this.divs[index - 1])
this.padcontent.prepend(newdivs);
// otherwise just add the new divs after the index-th div
else
this.divs[index - 1].after(newdivs);
// perform the splice on our array itself
// TODO: monkey patching divs.splice, so use divs.original_splice or something
return this.divs.splice(index, howMany, newdivs);
},
}
);
function loadBroadcastRevisionsJS(clientVars, connection)
{
2013-12-10 17:57:16 +02:00
revisionCache = new RevisionCache(connection, clientVars.collab_client_vars.rev || 0);
2013-12-09 15:58:17 +02:00
// revisionInfo.latest = clientVars.collab_client_vars.rev || -1;
2013-12-10 17:57:16 +02:00
var collabClientVars = clientVars.collab_client_vars;
p = new PadClient(collabClientVars.rev, collabClientVars.time, collabClientVars.initialAttributedText.text, collabClientVars.initialAttributedText.attribs, collabClientVars.apool);
cl = new ChangesetLoader(connection);
2013-12-09 15:58:17 +02:00
return cl;
}
exports.loadBroadcastRevisionsJS = loadBroadcastRevisionsJS;