c9-core/plugins/c9.vfs.extend/collab-server.js

2972 wiersze
114 KiB
JavaScript

// Uglify
// uglifyjs -c -m -o collab-server.js plugins/c9.vfs.extend/collab-server.js
// Deploy to shared space:
// scp -o LogLevel=ERROR -o StrictHostKeyChecking=no -i c9/node_modules/settings/keys/deploy collab-server.js 52ee501f50044657c4000005@project-livec99d49a9ef92.rhcloud.com:/var/lib/openshift/52ee501f50044657c4000005/app-root/data/759814/root/c9-vfs-extend/collab-server.js
"use strict";
var Fs = require("fs");
var Path = require("path");
var net = require("net");
var Stream = require("stream").Stream;
var crypto = require('crypto');
var events = require("events");
var exists = Fs.exists || Path.exists;
var localfsAPI; // Set on VFS register
var DEFAULT_NL_CHAR_FILE = "\n";
var DEFAULT_NL_CHAR_DOC = "";
// Models
var User, Document, Revision, Workspace, ChatMessage;
var basePath;
var PID;
var dbFilePath;
// Cache the workspace state got from the database
var cachedWS;
var cachedUsers;
var debug = false;
function getHomeDir() {
return process.env.OPENSHIFT_DATA_DIR || process.env.HOME;
}
function getProjectWD() {
var env = process.env;
var pidStr = env.BASE_PROC ? "" : String(PID);
return Path.resolve(env.BASE_PROC || env.OPENSHIFT_DATA_DIR || env.HOME, ".c9", pidStr);
}
/**
* Checks if the collab server required modules are installed
* npm: sqlite3 & sequelize
*/
function installServer(callback) {
function checkInstalled() {
try {
require("sqlite3");
require("sequelize");
return true;
} catch (err) {
return false;
}
}
if (!checkInstalled()) {
var err = new Error("[vfs-collab] Missing dependencies - NODE_PATH: " + process.env.NODE_PATH + "; node " + process.version);
err.code = "EFATAL";
return callback(err);
}
callback();
}
/**
* Wrap Sequelize callback-style to NodeJS"s standard callback-style
*/
function wrapSeq(fun, next) {
return fun.success(function () {
next.apply(null, [null].concat(Array.prototype.slice.apply(arguments)));
}).error(next);
}
/**
* Initialize the collab server sqlite3 database
* - Define modules mapping to tables
* - Declare relationships
* - Sync sequelize modules
* - Create and cache the Workspace metadata
* - Set synchronous = 0 for fastest IO performance
* - Create indices, if not existing
*
* @param {Boolean} readonly Whether the intention is only to read from the
* database (if true, initialization is skipped)
* @param {Function} callback
*/
function initDB(readonly, callback) {
var Sequelize = require("sequelize");
var MAX_LOG_LINE_LENGTH = 151;
dbFilePath = dbFilePath || Path.join(getProjectWD(), "collab.db");
var sequelize = new Sequelize("c9-collab", "c9", "c9-collab-secret", {
// the sql dialect of the database
dialect: "sqlite",
omitNull: true,
storage: dbFilePath,
// capture only the most important pieces of a sql statement or query
logging: function (log) {
if (!debug)
return;
var lines = log.split(/\r\n|\n|\r/);
var firstLine = lines[0];
firstLine = firstLine.length < MAX_LOG_LINE_LENGTH ? firstLine : (firstLine.substring(0, MAX_LOG_LINE_LENGTH) + "...");
var lastLine = lines[lines.length-1];
lastLine = lastLine.length < MAX_LOG_LINE_LENGTH ? lastLine : (lastLine.substring(lastLine.length - MAX_LOG_LINE_LENGTH) + "...");
console.error("[vfs-collab] DB", lines.length === 1
? (lines[0].length <= (2*MAX_LOG_LINE_LENGTH) ? lines[0] : (firstLine + lines[0].substring(Math.max(MAX_LOG_LINE_LENGTH, lines[0].length - MAX_LOG_LINE_LENGTH))))
: (firstLine + " --- " + lastLine));
},
define: {
// don"t use camelcase for automatically added attributes but underscore style
// so updatedAt will be updated_at
underscored: true,
freezeTableName: false,
charset: "utf8",
collate: "utf8_general_ci",
classMethods: {},
instanceMethods: {}
},
// sync after each association (see below). If set to false, you need to sync manually after setting all associations. Default: true
syncOnAssociation: true,
// use pooling in order to reduce db connection overload and to increase speed
// currently only for mysql and postgresql (since v1.5.0)
pool: { maxConnections: 5, maxIdleTime: 30}
});
Store.User = User = sequelize.define("User", {
uid: { type: Sequelize.STRING, primaryKey: true },
fullname: { type: Sequelize.STRING },
email: { type: Sequelize.STRING }
}, {
timestamps: true
});
Store.Workspace = Workspace = sequelize.define("Workspace", {
authorPool: { type: Sequelize.TEXT }, // Stringified JSON - uid -> 1,2, ...etc.
colorPool: { type: Sequelize.TEXT }, // Stringified JSON - uid --> "{r: 256, g: 0, b: 0}"
basePath: { type: Sequelize.STRING, allowNull: false },
migration: { type: Sequelize.INTEGER, defaultValue: 0 }
}, {
timestamps: true
});
Store.Document = Document = sequelize.define("Document", {
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
path: { type: Sequelize.STRING, unique: true },
contents: { type: Sequelize.TEXT },
fsHash: { type: Sequelize.STRING },
authAttribs: { type: Sequelize.TEXT }, // Stringified JSON
starRevNums: { type: Sequelize.TEXT }, // Stringified JSON list of integers
revNum: { type: Sequelize.INTEGER, defaultValue: 0 },
created_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
updated_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
newLineChar: { type: Sequelize.STRING, defaultValue: DEFAULT_NL_CHAR_DOC }, // "" or "\n" or "\r\n"
}, {
timestamps: false
});
Store.Revision = Revision = sequelize.define("Revision", {
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
operation: { type: Sequelize.TEXT }, // Stringified JSON Array - can be empty for rev:0
author: { type: Sequelize.STRING }, // userId if exists, 0 in syncing operations, -1 in undo non authored text
revNum: { type: Sequelize.INTEGER },
created_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
updated_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }
}, {
timestamps: false
});
Store.ChatMessage = ChatMessage = sequelize.define("ChatMessage", {
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
text: { type: Sequelize.STRING },
userId: { type: Sequelize.STRING, allowNull: false },
timestamp: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }
}, {
timestamps: true
});
Document.hasMany(Revision);
Revision.belongsTo(Document);
if (readonly)
return callback();
// Add migrations here e.g. ALTER TABLE ... ADD COLUMN ...
var migrations = [
{ query: "CREATE INDEX DocumentRevisionsIndex ON Revisions(document_id)", skipError: true },
{ query: "CREATE INDEX ChatMessageTimestampIndex ON ChatMessages(timestamp)", skipError: true },
{ query: "DELETE FROM Documents" },
{ query: "DELETE FROM Revisions" },
{ query: "ALTER TABLE Documents ADD COLUMN newLineChar VARCHAR(255)" }
];
async.series([
function(next) {
// http://www.sqlite.org/pragma.html
wrapSeq(sequelize.query("PRAGMA synchronous = 0;"), next);
},
// Document.drop(), // Cleanup on init
// Revision.drop(), // Cleanup on init
function(next) {
wrapSeq(User.sync(), next);
},
function(next) {
wrapSeq(Workspace.sync(), next);
},
function(next) {
wrapSeq(sequelize.query("ALTER TABLE Workspaces ADD COLUMN migration INTEGER DEFAULT 0"), next.bind(null, null));
},
function(next) {
wrapSeq(Document.sync(), next);
},
function(next) {
wrapSeq(Revision.sync(), next);
},
function(next) {
wrapSeq(ChatMessage.sync(), next);
},
function (next) {
wrapSeq(Workspace.findOrCreate({id: 1}, {
authorPool: "{}",
colorPool: "{}",
basePath: basePath,
migration: migrations.length
}), function(err, ws) {
if (err)
return next(err);
ws.authorPool = JSON.parse(ws.authorPool);
ws.colorPool = JSON.parse(ws.colorPool);
cachedWS = ws;
next();
});
},
function (next) {
var migNum = cachedWS.migration || 0;
if (migNum === migrations.length)
return next();
var migApplied = migNum;
async.forEachSeries(migrations.slice(migNum - migrations.length), function (migration, next) {
console.error("[vfs-collab] applying database migration:", migration.query);
wrapSeq(sequelize.query(migration.query), function(err) {
if (err && !migration.skipError)
return next(err);
migApplied++;
cachedWS.migration = migApplied;
Store.saveWorkspaceState(cachedWS, next);
});
}, function(err) {
if (cachedWS.migration != migrations.length)
err = (err ? (err + " -- ") : "") + "Not all migrations could be applied!";
next(err);
});
}
], function(err) {
if (!err)
return callback();
console.error("[vfs-collab] initDB attemp failed:", err);
if (err.code !== "SQLITE_CORRUPT" && err.code !== "SQLITE_NOTADB")
return callback(err); // not sure how to handle any other errors if any
console.error("[vfs-collab] initDB found a corrupted database - backing up and starting with a fresh collab database");
Fs.rename(dbFilePath, dbFilePath + ".old", function (err) {
if (err)
return callback(err);
initDB(readonly, callback);
});
});
}
/**************** operations.js ******************/
var operations = (function() {
/**
* Get a diff operation to transform a text document from: `fromText` to `toText`
*
* @param {String} fromText
* @param {String} toText
* @return {Operation} op
*/
function operation(fromText, toText) {
var dmp = new diff_match_patch();
var diffs = dmp.diff_main(fromText, toText);
dmp.diff_cleanupSemantic(diffs);
var d, type, val;
var op = [];
for (var i = 0; i < diffs.length; i++) {
d = diffs[i];
type = d[0];
val = d[1];
switch(type) {
case DIFF_EQUAL:
op.push("r" + val.length);
break;
case DIFF_INSERT:
op.push("i" + val);
break;
case DIFF_DELETE:
op.push("d" + val);
break;
}
}
return op;
}
// Simple edit constructors.
function insert(chars) {
return "i" + chars;
}
function del(chars) {
return "d" + chars;
}
function retain(n) {
return "r" + String(n);
}
/**
* Return the type of a sub-edit
*
* @param {String} edit
* @return {String} type of the operation
*/
function type(edit) {
switch (edit[0]) {
case "r":
return "retain";
case "d":
return "delete";
case "i":
return "insert";
default:
throw new TypeError("Unknown type of edit: ", edit);
}
}
/**
* Return the value of a sub-edit
*
* @param {String} sub-edit
* @return the value of the operation
* - Retain: the number of characters to retain
* - Insert/Delete: the text to insert or delete
*/
function val(edit) {
return type(edit) === "retain" ? ~~edit.slice(1) : edit.slice(1);
}
/**
* Return the length of a sub-edit
*
* @param {String} edit
* @return {Number} the length of the operation
* - Retain: the number of characters to retain
* - Insert/Delete: the text length to insert or delete
*/
function length(edit) {
return type(edit) === "retain" ? ~~edit.slice(1) : edit.length - 1;
}
/**
* Split a sub-edit on a index: idx
*
* @param {String} edit
* @return [{String}] an array of length 2 of the sub-operaion splitted to 2 operaions
*/
function split(edit, idx) {
if (type(edit) === "retain") {
var rCount = ~~edit.slice(1);
return [
"r" + idx,
"r" + (rCount - idx)
];
}
else {
return [
edit[0] + edit.substring(1, idx + 1),
edit[0] + edit.substring(idx + 1)
];
}
}
/**
* Pack an operation to a minimal operation
*
* @param {Operation} op
* @return {Operation} packed
*/
function pack(op) {
var packed = op.slice();
var i = 0;
while (i < packed.length - 1) {
if (packed[i][0] === packed[i+1][0])
packed.splice(i, 2, packed[i][0] + (val(packed[i]) + val(packed[i+1])));
else
i++;
}
return packed;
}
/**
* Inverse an operation to undo revert its effect on a document
*
* @param {Operation} op
* @return {Operation} inversed
*/
function inverse(op) {
var edit, t, v, inversed = new Array(op.length);
for (var i = 0, el = op.length; i < el; i++) {
edit = op[i];
t = type(edit);
v = val(edit);
switch (t) {
case "retain":
inversed[i] = op[i];
break;
case "insert":
inversed[i] = del(v);
break;
case "delete":
inversed[i] = insert(v);
break;
}
}
return inversed;
}
return {
insert: insert,
del: del,
retain: retain,
type: type,
val: val,
length: length,
split: split,
pack: pack,
operation: operation,
inverse: inverse
};
})();
/**************** apply.js ******************/
function OTError(expected, actual) {
var err = new Error("OT removed text mismatch");
err.expected = expected;
err.actual = actual;
err.code = "EMISMATCH";
return err;
}
/**
* Apply an operation on a string document and return the resulting new document text.
*
* @param {Opeartion} op - e.g. ["r2", "iabc", "r12"]
* @param {String} doc
* @return {String} newDoc
*/
function applyContents(op, doc) {
var val, newDoc = "";
for (var i = 0, len = op.length; i < len; i += 1) {
val = op[i].slice(1);
switch (op[i][0]) {
case "r": // retain
val = Number(val);
newDoc += doc.slice(0, val);
doc = doc.slice(val);
break;
case "i": // insert
newDoc += val;
break;
case "d": // delete
if (doc.indexOf(val) !== 0)
throw new OTError(val, doc.slice(0, 10));
else
doc = doc.slice(val.length);
break;
default:
throw new TypeError("Unknown operation: " + operations.type(op[i]));
}
}
return newDoc;
}
/**************** author_attributes.js ******************/
/**
* This is a specifically designed data structure that tends to behave as a relaxed B-Tree
* to optimize author attributes processing time, disk usage and network overhead
*
* It optimizes on two main factors:
* - insert/delete/traversal/find time: The B-tree try to maintain a minimal depth, so minimal processing needed for those operations: O(log with base minKeySize)
* - Parsing/Stringification time and disk usage: the nodes are implemented as arrays with the first element
* indicating the number of entries in the node
*
* @param minKeySize - the minimum number of entries in a node
* @param maxKeySize - the maximum number of entries in a node
*
* @author Mostafa
* @author Harutyun
*/
function AuthorAttributes(minKeySize, maxKeySize) {
// 2 * x ---> [length, [value]]
minKeySize = minKeySize || 20; // 4
maxKeySize = maxKeySize || (5 * minKeySize); // 8
function addValue(nodes, index, startI, length, id) {
var i = startI;
var len = nodes[i];
var val = nodes[i+1];
if (index < 0 || index > len)
throw new Error("Invalid index passed!");
if (val === id) {
nodes[i] += length;
} else if (index === len) {
if (nodes[i+3] == id)
nodes[i+2]+=length;
else
nodes.splice(i + 2, 0, length, id);
} else if (index === 0) {
if (nodes[i-1] == id)
nodes[i-2] += length;
else
nodes.splice(i , 0, length, id);
} else {
nodes.splice(i, 2, index, val, length, id, len - index, val);
}
}
function split(parent, nodes, pos) {
var splitPos = (nodes.length >> 2) << 1;
var leftLen = 0, rightLen = 0;
var right = nodes.splice(splitPos, splitPos + 2);
for (var i = 0; i < right.length; i += 2)
rightLen += right[i];
if (parent) {
parent.splice(pos + 2, 0, rightLen, right);
parent[pos] -= rightLen;
} else {
var left = nodes.splice(0, splitPos + 2);
for (var i = 0; i < left.length; i += 2)
leftLen += left[i];
nodes.push(leftLen, left, rightLen, right);
}
}
function insert(nodes, index, length, id) {
if (nodes.length === 0) {
nodes.push(length, id);
return;
}
var spilled = _insert(nodes, index, length, id);
if (spilled)
split(null, nodes, null);
// sanityCheck(nodes)
}
function _insert(nodes, index, length, id) {
for (var i = 0; i < nodes.length; i += 2) {
var len = nodes[i];
if (index <= len) {
var node = nodes[i+1];
if (Array.isArray(node)) {
nodes[i] += length;
var spilled = _insert(node, index, length, id);
if (spilled)
split(nodes, nodes[i+1], i);
}
else {
addValue(nodes, index, i, length, id);
}
return nodes.length > maxKeySize;
}
index -= len;
}
}
function remove(nodes, index, length) {
// console.log("remove:", index, length);
var removedTotal = 0;
for (var i = 0; i < nodes.length; i += 2) {
var len = nodes[i]; // node.length
var ch = nodes[i + 1];
var removed;
if (index <= len) {
if (Array.isArray(ch))
removed = remove(ch, index, length);
else
removed = Math.max(0, Math.min(length, len - index));
// console.log("Removed:", removed);
nodes[i] -= removed; // node.length
length -= removed;
removedTotal += removed;
if (!nodes[i]) {
nodes.splice(i, 2);
i -= 2;
}
else if (Array.isArray(ch) && ch.length < minKeySize &&
(ch.length + nodes.length) <= maxKeySize) {
// Move elements from child to parent
nodes.splice.apply(nodes, [i, 2].concat(ch));
}
if (!length)
break;
index = 0;
}
else {
index -= len;
}
}
for (var j = 0; j < nodes.length - 2; j += 2) {
// console.log("CHECK:", nodes[j].id, nodes[j+1].id);
if (!nodes[j] || nodes[j+1] !== nodes[j+3])
continue;
nodes[j] += nodes[j + 2];
nodes.splice(j+1, 2);
j -= 2;
}
// sanityCheck(nodes);
return removedTotal;
}
function apply(nodes, op, authPoolId) {
authPoolId = authPoolId || 0;
var index = 0;
var opLen;
for (var i = 0; i < op.length; i++) {
opLen = operations.length(op[i]);
switch (operations.type(op[i])) {
case "retain":
index += opLen;
break;
case "insert":
insert(nodes, index, opLen, authPoolId);
index += opLen;
break;
case "delete":
remove(nodes, index, opLen);
break;
default:
throw new TypeError("Unknown operation: " + operations.type(op[i]));
}
}
}
return {
apply: apply,
// insert: insert,
// remove: remove
};
}
var applyAuthororAttributes = AuthorAttributes().apply;
/**
* Hash a string (document content) for easier comparison of state changes
*/
function hashString(str) {
// if ((str + "").indexOf("\r") != -1) debugger
return crypto.createHash('md5').update(str).digest("hex");
}
/**
* Normalize document path to discard workspace prefix
*/
function getDocPath(path) {
if (path.indexOf(basePath) === 0)
return path.substring(basePath.length+1);
return path;
}
var emitter = new events.EventEmitter();
/**
* Document Store database wrapper utility to ease persistence/retrieval and update of entities such as:
* Documents, Revisions, Workspace, ChatMessages, Users
*/
var Store = (function () {
/**
* Create a `Document` from a template with path, contents
* Also, create its Revision#0 record
* @param {Object} tmpl
* @param {Function} callback
*/
function newDocument(tmpl, callback) {
var contents = tmpl.contents || "";
wrapSeq(Document.create({
contents: new Buffer(contents),
path: tmpl.path,
fsHash: tmpl.fsHash || hashString(contents),
authAttribs: contents.length ? JSON.stringify([contents.length, null]) : "[]",
starRevNums: "[]",
newLineChar: tmpl.newLineChar || DEFAULT_NL_CHAR_DOC,
revNum: 0
}), function (err, doc) {
if (err)
return callback(err);
wrapSeq(Revision.create({
document_id: doc.id,
operation: new Buffer("[]"),
revNum: 0
}), function (err, rev) {
if (err)
return callback(err);
doc.revisions = parseRevisions([rev]);
callback(null, parseDocument(doc));
});
});
}
/*
function moveDocument(docId, newPath, callback) {
wrapSeq(Document.find(docId), function (err, doc) {
if (err || !doc)
return callback(err || "No document found to rename!");
doc.path = newPath;
wrapSeq(doc.save(), callback);
});
}
*/
function parseDocument(doc) {
if (doc.authAttribs)
doc.authAttribs = JSON.parse(doc.authAttribs);
if (doc.starRevNums)
doc.starRevNums = JSON.parse(doc.starRevNums);
doc.contents = doc.contents && doc.contents.toString(); // because it can be a buffer
return doc;
}
function parseDocumentCallback(callback) {
return function (err, doc) {
if (err || !doc)
return callback(err);
callback(null, parseDocument(doc));
};
}
/**
* Get a `Document` from the database given its path
* @param {String} path the document path to query the database with
* @param [{String}] attributes - optional
* @param {Function} callback
* @param {Object} callback.err
* @param {Object} callback.result The result, or null if getDocument() failed (might even though err is null)
*/
function getDocument(path, attributes, callback) {
var query = { where: {path: getDocPath(path)} };
if (!callback) {
callback = attributes;
attributes = undefined;
}
else {
attributes.unshift("id");
query.attributes = attributes; // ["id", other attributes]
}
return wrapSeq(Document.find(query), parseDocumentCallback(callback));
}
/**
* Get the revisions of a certain document
* @param {Document} doc
* @param {Function} callback
*/
function getRevisions(doc, callback) {
wrapSeq(doc.getRevisions(), function (err, revisions) {
if (err)
return callback(err);
callback(null, parseRevisions(revisions));
});
}
/**
* In-place parsing of revisions
* @param [{Revision}] revisions
*/
function parseRevisions(revisions) {
revisions.forEach(function (rev) {
// rev.operation can be a buffer and is always a stringified JSON array
rev.operation = JSON.parse(rev.operation.toString());
});
revisions.sort(function(a, b) {
return a.revNum - b.revNum;
});
return revisions;
}
function prepareAttributes(doc, attributes) {
var update = {};
for (var i = 0; i < attributes.length; i++)
update[attributes[i]] = doc[attributes[i]];
return update;
}
/**
* Save a document with changes to the database
* @param [{String}] attributes - optional
* @param {Function} callback
*/
function saveDocument(doc, attributes, callback) {
if (!callback) {
callback = attributes;
attributes = undefined;
}
else {
// attributes.push("updated_at");
}
var authAttribs = doc.authAttribs;
var starRevNums = doc.starRevNums;
doc.authAttribs = JSON.stringify(authAttribs);
doc.starRevNums = JSON.stringify(starRevNums);
doc.contents = new Buffer(doc.contents);
// doc.updated_at = new Date(doc.updated_at);
return wrapSeq(
attributes ? doc.updateAttributes(prepareAttributes(doc, attributes)) : doc.save(),
function(err) {
doc.authAttribs = authAttribs;
doc.starRevNums = starRevNums;
callback(err, doc);
}
);
}
/**
* Gets the latest workspace state with the most important properties being: aurhorPool and colorPool
* @param {Function} callback
*/
function getWorkspaceState(callback) {
// the table has only a single entry
if (cachedWS)
return callback(null, cachedWS);
wrapSeq(Workspace.find(1), function (err, ws) {
if (err || !ws)
return callback(err || "No workspace state found!");
ws.authorPool = JSON.parse(ws.authorPool);
ws.colorPool = JSON.parse(ws.colorPool);
cachedWS = ws;
callback(null, ws);
});
}
/**
* Save the workspace with changes to the database
* @param {Workspace} ws
* @param {Function} callback
*/
function saveWorkspaceState(ws, callback) {
var authorPool = ws.authorPool;
var colorPool = ws.colorPool;
ws.authorPool = JSON.stringify(authorPool);
ws.colorPool = JSON.stringify(colorPool);
return wrapSeq(ws.save(), function(err, savedWS) {
if (err) {
cachedWS = null;
return callback(err);
}
savedWS.authorPool = authorPool;
savedWS.colorPool = colorPool;
cachedWS = savedWS;
callback(null, savedWS);
});
}
/**
* Save a document with changes to the database
* @param {Function} callback
*/
function getUsers(callback) {
if (cachedUsers)
return callback(null, cachedUsers);
wrapSeq(User.all(), function (err, users) {
cachedUsers = users;
callback(err, users);
});
}
/**
* Add uer's chat message to the database
* @param {String} text
* @param {String} userId
* @param {Function} callback
*/
function saveChatMessage(text, userId, callback) {
wrapSeq(ChatMessage.create({
text: text,
userId: userId
}), callback);
}
/**
* Get the most recent chat messages
* @param {Number} limit - optional
* @param {Function} callback
*/
function recentChatHistory(limit, callback) {
limit = limit || 100;
wrapSeq(ChatMessage.findAll({
order: 'timestamp DESC',
limit: limit
}), function(err, history) {
if (err)
return callback(err);
callback(null, history.reverse());
});
}
return {
newDocument: newDocument,
// moveDocument: moveDocument, // not used
getDocument: getDocument,
getRevisions: getRevisions,
saveDocument: saveDocument,
getWorkspaceState: getWorkspaceState,
saveWorkspaceState: saveWorkspaceState,
getUsers: getUsers,
saveChatMessage: saveChatMessage,
recentChatHistory: recentChatHistory
};
})();
// This object should have the following structure:
//
// { <document id> : { <client id> : true } }
var documents = {};
// This object should have the following structure:
//
// { <document id> : { fs.FSWatcher } }
var watchers;
// This object should have the following structure:
//
// { <client id> : <client> }
var clients;
// SQLite doesn't provide atomic instructions or locks
// So this variable expresses in-process locks
// Used to block concurrent edit updates while the document is being processed
//
// { <key or document_id> : [{Function}] }
var locks = {};
function lock(key, callback) {
if (!locks[key]) {
locks[key] = [];
return callback();
}
var watchdog = setTimeout(function() {
throw Error("[vfs-collab] Lock timeout"); // log & suicide
}, 60000);
return locks[key].push(function() {
clearTimeout(watchdog);
callback();
});
}
function unlock(key) {
var lock = locks[key];
if (!lock || !lock.length)
return delete locks[key];
var next = lock.shift();
next();
}
// Selected using colors.html
var featuredColors = [
{r: 255, g: 146, b: 45},
{r: 157, g: 47, b: 254},
{r: 105, g: 215, b: 83},
{r: 255, g: 105, b: 130},
{r: 200, g: 109, b: 218},
{r: 210, g: 230, b: 51},
{r: 6, g: 134, b: 255},
{r: 254, g: 13, b: 244},
{r: 188, g: 255, b: 86},
{r: 255, g: 212, b: 125},
{r: 107, g: 4, b: 255},
{r: 66, g: 248, b: 255}
];
// An algorithm to select bright random colors
function randomColor() {
var a,b,c;
do {
a = Math.random();
b = Math.random();
c = Math.max(a,b);
} while (c < 0.001);
// scale them such that the larger number scales to 1.0f
var scale = 1.0 / c;
a *= scale;
b *= scale;
// Pick third value, ensure it's dark.
c = Math.random() * 0.5;
var rgb = new Array(3);
var idx = Math.floor(Math.random() * 3) % 3;
rgb[idx] = a;
var rnd2 = Math.floor(Math.random() * 2) + 1;
var idx2 = (rnd2 + idx) % 3;
rgb[idx2] = b;
var idx3 = 3 - (idx + idx2);
rgb[idx3] = c;
rgb = rgb.map(function(x) {
return Math.floor(255 * x);
});
return {r: rgb[0], g: rgb[1], b: rgb[2]};
}
/**
* Handle new collab connections (can be reconnects)
* Sync user's info to the collab database and select a color and aurhor id for him/her if not previously set
* Send USER_JOIN notifications to other connected members
* Send handshake CONNECT message to the user with the needed workspace info and chat history
*/
function handleConnect(userIds, client) {
var userId = userIds.userId;
var clientId = userIds.clientId;
function done(err) {
if (!err)
return;
console.error(err);
client.send({
type: "CONNECT",
error: err
});
}
// Make sure to cache user's info
syncUserInfo();
function syncUserInfo() {
if (!userId)
return done("[vfs-collab] Anonyous users connections not supported");
var fullname = userIds.fullname;
var email = userIds.email;
wrapSeq(User.find({where: {uid: userId}}), function (err, user) {
if (err)
return done("[vfs-collab] syncUserInfo " + String(err));
if (!user) {
return wrapSeq(User.create({
uid: userId,
fullname: fullname,
email: email
}), function(err, createdUser) {
if (err)
return done("[vfs-collab] Failed creating user " + String(err));
cachedUsers && cachedUsers.push(createdUser);
augmentWorkspaceInfo();
});
}
if (user.fullname == fullname && user.email == email)
return augmentWorkspaceInfo();
user.fullname = fullname;
user.email = email;
wrapSeq(user.save(), function (err, user) {
if (err)
return done("[vfs-collab] Failed updating user " + String(err));
augmentWorkspaceInfo();
});
});
}
function augmentWorkspaceInfo() {
Store.getWorkspaceState(function (err, ws) {
if (err)
return done("[vfs-collab] augmentWorkspaceInfo " + String(err));
var authorPool = ws.authorPool;
var colorPool = ws.colorPool;
if (authorPool[userId] && colorPool[userId])
return doConnect(authorPool, colorPool);
if (!authorPool[userId])
authorPool[userId] = Object.keys(authorPool).length + 1;
if (!colorPool[userId])
colorPool[userId] = featuredColors[authorPool[userId]-1] || randomColor();
Store.saveWorkspaceState(ws, function (err) {
if (err)
return done("[vfs-collab] augmentWorkspaceInfo " + String(err));
doConnect(authorPool, colorPool);
});
});
}
function doConnect(authorPool, colorPool) {
Store.getUsers(function (err, users) {
if (err)
return done("[vfs-collab] getUsers " + String(err));
if (users.length > 1)
console.error("[vfs-collab] User", userIds.userId, "is connecting to a workspace with",
users.length - 1, "other workspace members");
var onlineUsers = {};
var idleUsers = {};
for (var clId in clients) {
var cl = clients[clId];
var uid = cl.userIds.userId;
if (!onlineUsers[uid])
onlineUsers[uid] = [];
onlineUsers[uid].push(clId);
var idleClinet = cl.state === "idle";
if (typeof idleUsers[uid] === "undefined")
idleUsers[uid] = idleClinet; // set through a USER_STATE message
else
idleUsers[uid] = idleUsers[uid] && idleClinet;
}
if (Object.keys(onlineUsers).length > 1)
console.error("[vfs-collab] User", userIds.userId, "is connecting Collab with",
Object.keys(clients).length-1, "other clients & online workspace members", onlineUsers);
var usersMap = {};
users.forEach(function (user) {
var uid = user.uid;
var onlineUserClients = onlineUsers[uid] || [];
var onlineState;
if (idleUsers[uid])
onlineState = "idle";
else if (onlineUserClients.length)
onlineState = "online";
else
onlineState = "offline";
usersMap[uid] = {
email: user.email,
fullname: user.fullname,
uid: user.uid,
clients: onlineUserClients,
online: onlineUserClients.length,
state: onlineState,
author: authorPool[uid],
color: colorPool[uid]
};
});
broadcast({
type: "USER_JOIN",
data: {
userId: userId,
clientId: clientId,
user: usersMap[userId]
}
}, client);
Store.recentChatHistory(100, function (err, chatHistory) {
if (err)
console.error("[vfs-collab] recentChatHistory", err);
client.send({
type: "CONNECT",
data: {
myClientId: clientId,
myUserId: userId,
fs: userIds.fs,
authorPool: authorPool,
colorPool: colorPool,
users: usersMap,
chatHistory: chatHistory || []
}
});
});
});
}
}
/**
* Returns true if the users has read access to the filesystem
*/
function collabReadAccess(fs) {
return (/r/).test(fs);
}
/**
* Returns true if the users has write access to the filesystem
*/
function collabWriteAccess(fs) {
return (/w/).test(fs);
}
/**
* Apply a user's operation to a document
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {String} docId - the document path
* @param {Document} doc - the document to apply the operation on
* @param {Operation} op - the operation to applly
* @param {Function} callback
*/
function applyOperation(userIds, docId, doc, op, callback) {
userIds = userIds || {userId: 0};
var userId = userIds.userId;
Store.getWorkspaceState(function (err, ws) {
if (err)
return callback(err);
try {
doc.contents = applyContents(op, doc.contents);
applyAuthororAttributes(doc.authAttribs, op, ws.authorPool[userId]);
wrapSeq(Revision.create({
operation: new Buffer(JSON.stringify(op)),
author: userId,
revNum: doc.revNum + 1,
document_id: doc.id
}), next);
} catch (e) {
return next(e);
}
});
function next(err) {
if (err)
return callback(err);
doc.revNum++;
Store.saveDocument(doc, /*["contents", "authAttribs", "revNum"],*/ function (err) {
if (err)
return callback(err);
var msg = {
docId: docId,
clientId: userIds.clientId,
userId: userId,
revNum: doc.revNum,
op: op
};
callback(null, msg);
});
}
}
/**
* Handle user's EDIT_UPDATE for a document
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - the EDIT_UPDATE message data with the document id, revision number and applied operation
*/
function handleEditUpdate(userIds, client, data) {
var docId = data.docId;
var clientId = userIds.clientId;
var newRev = data.revNum;
var docL;
function done(err) {
unlock(docId);
if (err) {
console.error("[vfs-collab]", err);
syncCommit(err);
}
}
// the user's edit couldn't be commited, please try again
function syncCommit(err) {
client.send({
type: "SYNC_COMMIT",
data: {
docId: docId,
revNum: docL && docL.revNum,
reason: err.message || err,
code: err.code || "SYNC_E"
}
});
}
if (!documents[docId] || !documents[docId][clientId] || !client.openDocIds[docId])
return done("Trying to update a non-member document!",
docId, clientId, documents[docId] && Object.keys(documents[docId]), Object.keys(client.openDocIds),
Object.keys(documents), Object.keys(clients));
if (!collabWriteAccess(userIds.fs))
return done("User " + userIds.userId + " doesn't have write access to edit document " + docId + " - fs: " + userIds.fs);
// Lock a document while updating - to stop any possible inconsistencies
lock(docId, function () {
Store.getDocument(docId, function (err, doc) {
if (err || !doc)
return done(err || ("No Document to update! " + docId));
docL = doc;
if (doc.revNum !== newRev-1) { // conflicting versions
var err2 = new Error("Version log: " + docId + " " + doc.revNum + " " + newRev);
err2.code = "VERSION_E";
return done(err2);
}
// message.author for udno auth attributes
applyOperation(userIds, docId, doc, data.op, function (err, msg) {
if (err) {
var err2 = new Error("OT Error: " + String(err));
err2.code = "OT_E";
return done(err2);
}
msg.selection = data.selection;
broadcast({
type: "EDIT_UPDATE",
data: msg
}, client, docId);
delete msg.op;
delete msg.selection;
client.send({
type: "EDIT_UPDATE",
data: msg
});
emitter.emit("afterEditUpdate", {
docId: docId,
path: getAbsolutePath(docId),
doc: doc
});
done();
});
});
});
}
/**
* Handle user's UPDATE_NL_CHAR for a document
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - the UPDATE_NL_CHAR message data with the document id, newLineChar
*/
function handleUpdateNlChar(userIds, client, data) {
var docId = data.docId;
var newLineChar = data.newLineChar || "";
var nlCharLog;
switch (newLineChar) {
case "\n":
nlCharLog = "\\n";
break;
case "\r\n":
nlCharLog = "\\r\\n";
break;
default:
nlCharLog = newLineChar.length + ":" + newLineChar;
return missingInfo();
}
if (!docId)
return missingInfo();
function missingInfo() {
console.error("[vfs-collab] updateNlChar missing info:", docId, nlCharLog);
}
function done(err) {
unlock(docId);
if (err)
console.error("[vfs-collab] updateNlChar failed:", err);
}
// Lock a document while updating - to stop any possible inconsistencies
lock(docId, function () {
Store.getDocument(docId, function(err, doc) {
if (err || !doc)
return done((err || "updateNlChar of a non-collab document!") + " : " + docId);
if (doc.newLineChar == newLineChar)
return done();
doc.newLineChar = newLineChar;
Store.saveDocument(doc, /*["newLineChar"],*/ function (err) {
if (err)
return done(err);
console.error("[vfs-collab] updateNlChar changed", newLineChar);
broadcast({
type: "UPDATE_NL_CHAR",
data: {
docId: docId,
newLineChar: newLineChar
}
}, client, docId);
done();
});
});
});
}
/**
* Handle user's CHAT_MESSAGE
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - the CHAT_MESSAGE data with the chat text
*/
function handleChatMessage(userIds, client, data) {
var text = data.text;
var userId = userIds.userId;
// Save the chat message and broadcast it
Store.saveChatMessage(text, userId, function (err, message) {
if (err)
return console.error("[vfs-collab] saveChatMessage:", err);
var msg = {
type: "CHAT_MESSAGE",
data: {
id: message.id,
userId: userId,
timestamp: message.timestamp,
text: text
}
};
broadcast(msg);
});
}
/**
* Handle user's CURSOR_UPDATE messages
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - the CURSOR_UPDATE data with the document id and the user selection
*/
function handleCursorUpdate(userIds, client, data) {
var docId = data.docId;
var clientId = userIds.clientId;
if (!documents[docId] || !documents[docId][clientId] || !client.openDocIds[docId])
return console.error("[vfs-collab] Trying to select in a non-member document!",
docId, clientId, documents[docId] && Object.keys(documents[docId]), Object.keys(client.openDocIds),
Object.keys(documents), Object.keys(clients));
documents[docId][clientId].selection = data.selection;
data.clientId = clientId;
data.userId = userIds.userId;
broadcast({
type: "CURSOR_UPDATE",
data: data
}, client, docId);
}
/**
* Broadcast a message to all or a selected group of connected collab clients
* @param {Object} message - the message to broadcast
* @param {Socket} sender - optional, when we want to exclude the sender from the group to send the message to
* @param {String} docId - the document id or path
*/
function broadcast(message, sender, docId) {
var toClientIds = docId ? documents[docId] : clients;
var audienceNum = 0;
for (var clientId in toClientIds) {
var client = clients[clientId];
// Exclude sender if exists
if (client === sender || !client)
continue;
client.send(message);
audienceNum++;
}
// if (audienceNum)
// console.error("[vfs-collab] Broadcast to:", audienceNum, "clients", message);
}
function getAbsolutePath(docId) {
if (docId[0] === "~")
return Path.join(getHomeDir(), docId.substring(1));
else
return Path.join(basePath, docId);
}
/**
* Watch documents for other filesystem changes and sync them back to the collab documents
* @param docId - the document id or path
*/
function initVfsWatcher(docId) {
var absPath = getAbsolutePath(docId);
function done(err) {
if (err)
console.error("[vfs-collab] WATCH ERR:", docId, err);
unlock(docId);
}
// Check if a collab document sync is needed, apply it and save to the filesystem
function doWatcherSync(stats, next) {
var mtime = new Date(stats.mtime).getTime();
var watcher = watchers[docId];
var timeDiff = mtime - watcher.mtime;
if (watcher.mtime && timeDiff < 1)
return;
lock(docId, function () {
console.error("[vfs-collab] WATCH SYNC:", docId, timeDiff);
watcher.mtime = mtime;
Store.getDocument(docId, function (err, oldDoc) {
if (err)
return next(err);
syncDocument(docId, oldDoc, function (err, doc2) {
if (err)
return next(err);
doSaveDocument(docId, doc2, -1, true, next);
});
});
});
}
localfsAPI.watch(absPath, {}, function (err, meta) {
if (err)
return console.error("[vfs-collab] WATCH INIT ERR:", docId, err);
var watcher = meta.watcher;
watcher.on("change", function (event, filename, stat, files) {
console.error("[vfs-collab] WATCH CHANGE:", docId, "mtime:", new Date(stat.mtime).getTime());
doWatcherSync(stat, done);
});
watcher.on("error", function(err){
console.error("[vfs-collab] WATCH ERR:", docId, err);
});
watchers[docId] = watcher;
watcher.mtime = Date.now();
Fs.stat(absPath, function (err, stat) {
if (err) return;
watcher.mtime = new Date(stat.mtime).getTime();
});
});
}
/**
* Handle user's JOIN_DOC messages - a user is joining a document to collaborate on
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - the JOIN_DOC data with the document id
*/
function handleJoinDocument(userIds, client, data) {
var docId = data.docId;
var clientId = userIds.clientId;
var userId = userIds.userId;
function done(err) {
if (err) {
console.error("[vfs-collab] handleJoinDocument ERR:", docId, err);
client.send({
type: "JOIN_DOC",
data: {
clientId: clientId,
docId: docId,
err: err
}
});
}
unlock(docId);
}
lock(docId, function() {
Store.getDocument(docId, function(err, doc) {
if (err)
return done("getDocument " + String(err));
if (doc && documents[docId])
return fetchMetadataThenJoinDocument(doc);
console.error("[vfs-collab] Joining a closed document", docId, " - Syncing");
syncDocument(docId, doc, function(err, doc2) {
if (err)
return done(err);
fetchMetadataThenJoinDocument(doc2);
});
});
});
function fetchMetadataThenJoinDocument(doc) {
localfsAPI.getMetadata(docId, { sandbox: basePath }, function(err, metadata) {
if (err)
console.error("[vfs-collab] Warning: failed to fetch metadata!", docId, err);
var file = getAbsolutePath(docId);
isVeryLargeFile(file, doc.contents, function(err, isLarge) {
if (err)
console.error("[vfs-collab] isVeryLargeFile failed:", err);
if (!isLarge)
return joinDocument(doc, String(metadata || ""));
client.send({
type: "LARGE_DOC",
data: {
userId: userId,
clientId: clientId,
docId: docId,
response: true
}
});
});
});
}
function joinDocument(doc, metadata) {
if (!documents[docId]) {
documents[docId] = {};
initVfsWatcher(docId);
console.error("[vfs-collab] User", userId, "is joining document", docId);
}
else {
console.error("[vfs-collab] User", userId, "is joining a document", docId, "with",
Object.keys(documents[docId]).length, "other document members");
}
var docHash = hashString(doc.contents);
var clientDoc = JSON.stringify({
selections: documents[docId],
authAttribs: doc.authAttribs,
contents: doc.contents.toString(),
metadata: metadata,
fsHash: doc.fsHash,
docHash: docHash,
revNum: doc.revNum,
newLineChar: doc.newLineChar,
created_at: doc.created_at,
updated_at: doc.updated_at
});
documents[docId][clientId] = userIds;
client.openDocIds[docId] = true;
// Cut the document to pices and stream to the client
var chunkSize = 10*1024; // 10 KB
var contentsLen = clientDoc.length;
var chunksLen = Math.ceil(contentsLen / chunkSize);
for (var i = 0; i < contentsLen; i += chunkSize) {
var chunk = clientDoc.slice(i, i + chunkSize);
client.send({
type: "JOIN_DOC",
data: {
userId: userId,
clientId: clientId,
docId: docId,
reqId: data.reqId,
chunkNum: (i / chunkSize) + 1,
chunksLength: chunksLen,
chunk: chunk
}
});
}
broadcast({
type: "JOIN_DOC",
data: {
docId: docId,
userId: userId,
clientId: clientId
}
}, client);
done();
}
}
/**
* Normalize text line terminators for collab index-based calculations to seamlessly work
* @param {String} text
* @return {String} normalized
*/
function normalizeTextLT(text) {
return text.replace(/\r\n|\r/g, "\n");
}
// return "\n" or "\r\n" or null
function detectNewLineChar(text) {
// Must be the strictly same as on the client
// (and note that Ace doesn't have \r newLine mode)
var match = text.match(/^.*?(\r\n|\n)/m);
return match && match[1];
}
/**
* Synchronize collab document state with the filesystem state (utilizing hashes)
*
* @param {String} docId - the document id or path
* @param {Document} doc - the collab document
* @param {Function} callback
*/
function syncDocument(docId, doc, callback) {
var file = getAbsolutePath(docId);
isBinaryFile(file, function (err, isBinary) {
if (err)
return callback(new Error("SYNC: Binary check failed - ERR: " + String(err)));
if (isBinary)
return callback(new Error("SYNC: Binary file opened " + isBinary));
isVeryLargeFile(file, null, function(err, isLarge) {
if (err)
return callback(err);
if (!isLarge)
return doSyncDocument();
console.error("[vfs-collab] File is too large, ignoring: " + file);
err = new Error("File is too large");
err.code = "ELARGE";
callback(err);
});
});
function doSyncDocument() {
Fs.readFile(file, "utf8", function (err, contents) {
if (err)
return callback(err);
// "\n" or "\r\n" or null
var newLineChar = detectNewLineChar(contents);
var oldNewLineChar = doc && doc.newLineChar || DEFAULT_NL_CHAR_DOC;
var normContents = normalizeTextLT(contents);
var fsHash = hashString(normContents);
// HACK: fsHash from database is unreliable (https://github.com/c9/newclient/issues/3980)
if (doc)
doc.fsHash = hashString(doc.contents);
if (!doc) {
console.error("[vfs-collab] SYNC: Creating document:", docId, fsHash);
Store.newDocument({
path: docId,
contents: normContents,
fsHash: fsHash,
newLineChar: newLineChar
}, callback);
}
// update database OT state
else if (fsHash !== doc.fsHash && doc.contents != normContents) {
// if (doc.contents != normalizeTextLT(doc.contents)) {
// debugger
// doc.contents = normalizeTextLT(doc.contents);
// }
var op = operations.operation(doc.contents, normContents);
console.error("[vfs-collab] SYNC: Updating document:", docId, op.length, fsHash, doc.fsHash);
// non-user sync operation
doc.fsHash = fsHash; // applyOperation will save it for me
doc.newLineChar = newLineChar || oldNewLineChar;
applyOperation(null, docId, doc, op, function (err, msg) {
if (err)
return callback("SYNC: Failed updating OT database document state! " + String(err));
msg.sync = true;
broadcast({
type: "EDIT_UPDATE",
data: msg
}, null, docId);
checkNewLineChar();
callback(null, doc);
});
}
else {
checkNewLineChar();
callback(null, doc);
}
function checkNewLineChar() {
if (newLineChar && oldNewLineChar !== newLineChar) {
broadcast({
type: "UPDATE_NL_CHAR",
data: {
oldNewLineChar: oldNewLineChar,
newLineChar: newLineChar
}
}, null, docId);
doc.newLineChar = newLineChar || oldNewLineChar;
}
}
});
}
}
/**
* Handle user's GET_REVISIONS messages - retrive the revision history of the file
*
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - the JOIN_DOC data with the document id
*/
function handleGetRevisions(userIds, client, data) {
var docId = data.docId;
function done(err) {
if (err)
console.error("[vfs-collab] handleGetRevisions ERR:", docId, err);
unlock(docId);
}
lock(docId, function () {
Store.getDocument(docId, function (err, doc) {
if (err)
return done("getDocument " + String(err));
Store.getRevisions(doc, function (err, revisions) {
if (err || !revisions)
return done("getRevisions " + (revisions || []).length + " " + String(err));
var docRevisions = JSON.stringify({
revisions: revisions,
starRevNums: doc.starRevNums,
revNum: doc.revNum
});
// Cut the revisions into pices and stream to the client
var chunkSize = 10*1024; // 10 KB
var contentsLen = docRevisions.length;
var chunksLen = Math.ceil(contentsLen / chunkSize);
for (var i = 0; i < contentsLen; i += chunkSize) {
var chunk = docRevisions.slice(i, i + chunkSize);
client.send({
type: "GET_REVISIONS",
data: {
userId: userIds.userId,
clientId: userIds.clientId,
docId: data.docId,
chunkNum: (i / chunkSize) + 1,
chunksLength: chunksLen,
chunk: chunk
}
});
}
done();
});
});
});
}
/**
* Handle user's SAVE_FILE messages - save collab documents
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - the SAVE_FILE data with the document id and wether to sielently save (with auto-save enabled) or star the save
*/
function handleSaveFile(userIds, client, data) {
var st = Date.now();
var docId = data.docId;
var userId = userIds.userId;
function done(err) {
unlock(docId);
if (err) {
console.error("[vfs-collab]", err);
client.send({
type: "FILE_SAVED",
data: {
docId: docId,
err: err
}
});
}
}
console.error("[vfs-collab] Saving file", docId);
lock(docId, function () {
Store.getDocument(docId, ["contents", "revNum", "starRevNums"], function (err, doc) {
if (err || !doc)
return done((err || "Writing a non-collab document!") + " : " + docId);
if (watchers[docId])
watchers[docId].mtime = Date.now();
var absPath = getAbsolutePath(docId);
var fileContents = doc.contents.replace(/\n/g, doc.newLineChar || DEFAULT_NL_CHAR_FILE);
function mkfileWriteFile() {
var options = { bufferWrite: true };
var stream = options.stream = new Stream();
stream.readable = true;
localfsAPI.mkfile(absPath, options, writeFileCallback);
stream.emit("data", fileContents);
stream.emit("end");
}
/*
function regularWriteFile() {
Fs.writeFile(absPath, doc.contents, "utf8", writeFileCallback);
}
*/
function writeFileCallback(err) {
if (err)
return done("Failed saving file ! : " + docId + " ERR: " + String(err));
doSaveDocument(docId, doc, userId, !data.silent, function (err) {
console.error("[vfs-collab] Saving took", Date.now() - st, "ms - file:", docId, !err);
done(err);
});
}
mkfileWriteFile();
});
});
}
/**
* Apply the save to the collab document, update the hash and optionally add a star revision
* @param {String} docId - the document id or path
* @param {Document} doc - the collab document
* @param {String} userId - the user id
* @param {Boolean} star - add a star to the document if not triggered by auto-save
* @param {Function} callback
*/
function doSaveDocument(docId, doc, userId, star, callback) {
if (star && doc.starRevNums.indexOf(doc.revNum) === -1)
doc.starRevNums.push(doc.revNum);
var fsHash = doc.fsHash = hashString(doc.contents);
Store.saveDocument(doc, /*["fsHash", "starRevNums"],*/ function (err) {
if (err)
return callback(err);
console.error("[vfs-collab] starRevision added", doc.revNum);
var data = {
userId: userId,
docId: docId,
star: star,
revNum: doc.revNum,
fsHash: fsHash
};
broadcast({
type: "FILE_SAVED",
data: data
}, null, docId);
callback();
});
}
/**
* Handle user's LEAVE_DOC messages - client closing a collab document
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - the LEAVE_DOC data with the document id
*/
function handleLeaveDocument(userIds, client, data) {
var docId = data.docId;
var userId = userIds.userId;
var clientId = userIds.clientId;
if (!documents[docId] || !documents[docId][clientId] || !client.openDocIds[docId])
return console.error("[vfs-collab] Trying to leave a non-member document!",
docId, clientId, documents[docId] && Object.keys(documents[docId]), Object.keys(client.openDocIds),
Object.keys(documents), Object.keys(clients));
delete client.openDocIds[docId];
console.error("[vfs-collab]", clientId, "is leaving document", docId);
delete documents[docId][clientId];
if (!Object.keys(documents[docId]).length) {
console.error("[vfs-collab] Closing document", docId);
closeDocument(docId);
}
broadcast({
type: "LEAVE_DOC",
data: {
docId: docId,
userId: userId,
clientId: clientId
}
}, client);
}
/**
* Handle user's LARGE_DOC messages - document has grown too large for collab
*
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - the LEAVE_DOC data with the document id
*/
function handleLargeDocument(userIds, client, data) {
var docId = data.docId;
var userId = userIds.userId;
var clientId = userIds.clientId;
console.error("[vfs-collab] ", docId);
delete documents[docId][clientId];
if (!Object.keys(documents[docId]).length) {
console.log("[vfs-collab] File has grown too large, ignoring: " + docId);
closeDocument(docId);
}
broadcast({
type: "LARGE_DOC",
data: {
docId: docId,
userId: userId,
clientId: clientId
}
}, client);
}
/**
* Handle user's USER_STATE messages - update connected clients with user state
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - the JOIN_DOC data with the document id
*/
function handleUserState(userIds, client, data) {
var userId = userIds.userId;
var clientId = userIds.clientId;
console.error("[vfs-collab]", clientId, "is switching to", data.state);
clients[clientId].state = data.state;
var isUserIdle = Object.keys(clients)
.map(function(cliId) {
return clients[cliId];
}).filter(function(cl) {
return cl.userIds.userId === userId;
}).reduce(function(isIdle, cl) {
return isIdle && cl.state === "idle";
}, true);
broadcast({
type: "USER_STATE",
data: {
state: isUserIdle ? "idle" : "online",
userId: userId,
clientId: clientId
}
}, client);
}
/**
* Clears specific chat messages or complete chat history
*
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - either id: $id of the message to be deleted or clear: true to clear all of the chat history
*/
function handleClearChat(userIds, client, data) {
console.error("[vfs-collab] Clear chat history: ", data.id, data.clear, userIds.fs);
if (!collabWriteAccess(userIds.fs))
return console.error("[vfs-collab] clearChat: User don't have write access!");
var stmt;
if (data.clear)
stmt = ChatMessage.destroy({}, {truncate: true});
else if (data.id)
stmt = ChatMessage.destroy({id: data.id});
else
return console.error("[vfs-collab] clearChat: Invalid message", data);
wrapSeq(stmt, function(err) {
console.error("[vfs-collab] Chat clear:", err ? err : "SUCCESS");
if (err)
return;
broadcast({
type: "CLEAR_CHAT",
data: data
});
});
}
/**
* Clears specific chat messages or complete chat history
*
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - either id: $id of the message to be deleted or clear: true to clear all of the chat history
*/
function broadcastUserMessage(userIds, client, data) {
console.error("[vfs-collab] Clear chat history: ", data.id, data.clear, userIds.fs);
broadcast({
type: "MESSAGE",
data: data
}, client);
}
/**
* Handle any user message by routing to its proper handler
*
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the connected collab client
* @param {Object} data - the SAVE_FILE data with the document id and wether to sielently save (with auto-save enabled) or star the save
*/
function handleUserMessage(userIds, client, message) {
var data = message.data || {};
var docId = data.docId || "";
if (docId[0] === "/")
data.docId = docId.slice(1);
switch (message.type) {
case "JOIN_DOC":
handleJoinDocument(userIds, client, data);
break;
case "GET_REVISIONS":
handleGetRevisions(userIds, client, data);
break;
case "LEAVE_DOC":
handleLeaveDocument(userIds, client, data);
break;
case "LARGE_DOC":
handleLargeDocument(userIds, client, data);
break;
case "EDIT_UPDATE":
handleEditUpdate(userIds, client, data);
break;
case "UPDATE_NL_CHAR":
handleUpdateNlChar(userIds, client, data);
break;
case "CURSOR_UPDATE":
handleCursorUpdate(userIds, client, data);
break;
case "SAVE_FILE":
handleSaveFile(userIds, client, data);
break;
case "CHAT_MESSAGE":
handleChatMessage(userIds, client, data);
break;
case "USER_STATE":
handleUserState(userIds, client, data);
break;
case "CLEAR_CHAT":
handleClearChat(userIds, client, data);
break;
case "PING":
client.send({type: "PING"});
break;
case "MESSAGE":
broadcastUserMessage(userIds, client, data);
break;
default:
throw new Error("Unknown message message type: " + message.type);
}
}
/**
* @param {Object} userIds - user descriptor with: uid, email, fullname, fs, clientId
* @param {Socket} client - the just-connected collab client
*/
function onConnect(userIds, client) {
var userId = userIds.userId;
var clientId = userIds.clientId;
console.error("[vfs-collab] CONNECTED UserID: " + userId + " & ClientId: " + clientId);
client.on("message", function (messag) {
// console.error("[vfs-collab] Message from ", userIds, ": " + messag);
try {
messag = JSON.parse(messag);
} catch (e) {
return console.error("[vfs-collab] Can't parse client data!", messag);
}
try {
handleUserMessage(userIds, client, messag);
} catch (e) {
return console.error("[vfs-collab] Can't handle user messag", messag, e);
}
});
handleConnect(userIds, client);
client.on("disconnect", function () {
for (var docId in client.openDocIds)
handleLeaveDocument(userIds, client, {docId: docId});
broadcast({
type: "USER_LEAVE",
data: {
userId: userId,
clientId: clientId
}
}, client);
console.error("[vfs-collab] DISCONNECTED a socket with userId " + userId);
});
}
var compressTimers = {};
/**
* Close a document because it's no more open for collaboration, close the watcher and schedule a compression
* @param {String} docId - the document id or path
*/
function closeDocument(docId) {
delete documents[docId];
if (compressTimers[docId])
clearTimeout(compressTimers[docId]);
compressTimers[docId] = setTimeout(function () {
delete compressTimers[docId];
compressDocument(docId, {
MAX_REVISION_NUM: 256,
COMPRESSED_REV_NUM: 128
});
}, 100000);
if (watchers[docId]) {
watchers[docId].close();
delete watchers[docId];
}
}
/**
* Pack documents' revisions if they go beyond a certain threshould: options.MAX_REVISION_NUM
* to put it back to a reasonable number of revisions: options.COMPRESSED_REV_NUM
*
* It applies multiple heuristic algorithms to combine revisions trying not to lose any authorship information
*
* @param {String} docId - the document id or path
* @param {Object} options - compression configuration parameters
* @param {Function} callback
*/
function compressDocument(docId, options, callback) {
if (documents[docId])
return;
var ALREADY_COMPRESSED = "ALREADY_COMPRESSED";
var MAX_REVISION_NUM = options.MAX_REVISION_NUM;
var COMPRESSED_REV_NUM = options.COMPRESSED_REV_NUM;
var doc, revisions, path;
var newRevisions, newStarRevNums;
var starsHash, rev0Contents, lastRevTime, docTimeDiff, optimalRevTimeDiff;
// compaction modes
var mergeDifferentAuthors = false;
var isAggressive = false;
var secondTime = 1000;
var minuteTime = secondTime * 60;
var hourTime = minuteTime * 60;
var dayTime = hourTime * 24;
var fourDaysTime = dayTime << 2;
function done(err) {
unlock(docId);
if (err === ALREADY_COMPRESSED)
err = undefined;
if (err)
console.error("[vfs-collab] ERROR Closing Document", docId, err);
callback && callback(err);
}
function cloneRevision(rev, revNum) {
return {
document_id: rev.document_id,
operation: rev.operation.slice(),
author: rev.author,
revNum: revNum,
created_at: rev.created_at,
updated_at: rev.updated_at
};
}
function shouldMergeTimeDiff(rev, lastRev) {
if (lastRev.author != rev.author) {
if (mergeDifferentAuthors)
lastRev.author = "0";
else
return false;
}
var latestRevDiff = lastRevTime - rev.created_at;
var prevRevDiff = rev.created_at - lastRev.created_at;
if (isAggressive)
return prevRevDiff < (optimalRevTimeDiff << 1);
if (latestRevDiff < hourTime)
// previous revision is < 8-seconds away (co-editing)
return prevRevDiff < (secondTime << 3);
else if (latestRevDiff < dayTime)
// previous revision is < 4-minutes away
return prevRevDiff < (minuteTime << 2);
else if (latestRevDiff < fourDaysTime)
// previous revision is < 1-hour away
return prevRevDiff < (hourTime);
else
return prevRevDiff < optimalRevTimeDiff;
}
lock(docId, function() {
async.series([
function (next) {
Store.getDocument(docId, function (err, docL) {
if (err || !docL)
return next(err || "No document to close!");
path = docL.path;
doc = docL;
next();
});
},
function (next) {
Store.getRevisions(doc, function (err, revisionsL) {
if (err || !revisionsL)
return next(err || "No document revisions found!");
if (revisionsL.length < MAX_REVISION_NUM)
return next(ALREADY_COMPRESSED);
revisions = revisionsL;
next();
});
},
function prepare(next) {
// compress to the latest N/2 saves only
var newStars = doc.starRevNums.slice(-COMPRESSED_REV_NUM);
starsHash = {};
var i;
for (i = 0; i < newStars.length; i++)
starsHash[newStars[i]] = true;
rev0Contents = doc.contents;
for (i = revisions.length - 1; i > 0; i--) {
var op = operations.inverse(revisions[i].operation);
revisions[i].contents = rev0Contents;
rev0Contents = applyContents(op, rev0Contents);
}
lastRevTime = revisions[revisions.length-1].created_at;
docTimeDiff = lastRevTime - revisions[0].created_at;
optimalRevTimeDiff = docTimeDiff / COMPRESSED_REV_NUM;
next();
},
function compressDoc(next) {
var shouldCompress = revisions.length - COMPRESSED_REV_NUM;
console.error("[vfs-collab] Compress document trial", docId, shouldCompress, mergeDifferentAuthors, isAggressive);
newRevisions = [ cloneRevision(revisions[0], 0) ];
newStarRevNums = [];
var lastRev = {author: -9};
var prevContents, prevLastContents;
var lastContents = rev0Contents;
var i, rev;
for (i = 1; i < revisions.length && shouldCompress; i++) {
rev = revisions[i];
prevLastContents = lastContents;
lastContents = applyContents(rev.operation, lastContents);
// Check if can merge revisions and clear lastRev's author if different & can merge different authors
// TODO: remove the side-effect on parameters the function do
if (shouldMergeTimeDiff(rev, lastRev)) {
var compressedOp = operations.operation(prevContents, lastContents);
lastRev.operation = compressedOp;
shouldCompress--;
}
else {
lastRev = cloneRevision(rev, newRevisions.length);
newRevisions.push(lastRev);
prevContents = prevLastContents;
}
if (starsHash[i] && !lastRev.isStar) {
newStarRevNums.push(lastRev.revNum);
lastRev.isStar = true;
}
}
if (!shouldCompress) {
while (i < revisions.length) {
newRevisions.push(cloneRevision(revisions[i++], newRevisions.length));
}
}
else if (!mergeDifferentAuthors) {
console.error("[vfs-collab] Merge single-author failed to compact the document enough", revisions.length, newRevisions.length);
mergeDifferentAuthors = true;
return compressDoc(next);
}
else if (!isAggressive) {
console.error("[vfs-collab] Merge multi-author failed to compact the document enough", revisions.length, newRevisions.length);
isAggressive = true;
return compressDoc(next);
}
else if (newRevisions.length >= MAX_REVISION_NUM) {
console.error("[vfs-collab] All compression modes failed to compact the document enough", revisions.length, newRevisions.length);
}
console.error("[vfs-collab] Compressed document:", revisions.length, newRevisions.length,
"Different Authors:", mergeDifferentAuthors,
"isAggressive:", isAggressive);
// var newContents = rev0Contents;
// for (i = 1; i < newRevisions.length; i++) {
// var newRev = newRevisions[i];
// newContents = applyContents(newRev.operation, newContents);
// }
// console.error("[vfs-collab] Compressed document:", newContents == doc.contents, revisions.length, newRevisions.length);
// console.error("[vfs-collab] New Revisions:", newRevisions);
// console.error("[vfs-collab] Stars:", doc.starRevNums, newStarRevNums);
next();
},
function (next) {
wrapSeq(Revision.destroy({document_id: doc.id}), next);
},
function (next) {
doc.starRevNums = newStarRevNums;
doc.revNum = newRevisions.length - 1;
Store.saveDocument(doc, /*["revNum", "starRevNums"],*/ next);
},
function (next) {
newRevisions.forEach(function(newRev) {
delete newRev.isStar;
newRev.operation = JSON.stringify(newRev.operation);
});
wrapSeq(Revision.bulkCreate(newRevisions), next);
}
], done);
});
}
// ********* VFS Stream, net.Socket Collab Communication Infrastructure ************ //
/**
* Create the collab socket net.Server
* The net.Server is file-socket to allow multiple collab-enabled workspaces on SSH workspaces
*/
function createServer() {
var server = net.createServer(function(client) {
// console.error("[vfs-collab] Client connected");
var userIds;
var isClosed = false;
client.send = function (msg) {
if (isClosed)
return;
msg.command = msg.command || "vfs-collab";
var strMsg = JSON.stringify(msg);
client.write(strMsg + "\0\0");
};
client.on("data", function handShake(data) {
client.removeListener("data", handShake);
client.on("data", onData);
userIds = JSON.parse(data);
if (!collabReadAccess(userIds.fs))
return console.error("[vfs-collab] Client don't have read access to workspace! - " +
"Note that visitors of private workspaces can't use collab features");
client.userIds = userIds;
client.openDocIds = {};
clients[userIds.clientId] = client;
// console.error("[vfs-collab] Server handshaked", Object.keys(clients).length);
// handshaking the client
client.write(data.toString());
if (server.collabInited)
onConnect(userIds, client);
else
server.once("collabInited", function() {
onConnect(userIds, client);
});
});
var buff = [];
function onData(data) {
data = data.toString();
var idx;
while (true) {
idx = data.indexOf("\0\0");
if (idx === -1)
return data && buff.push(data);
buff.push(data.substring(0, idx));
var clientMsg = buff.join("");
data = data.substring(idx + 2);
buff = [];
client.emit("message", clientMsg);
}
}
client.on("close", onClose);
client.on("end", onClose);
function onClose() {
if (isClosed)
return;
isClosed = true;
delete clients[userIds.clientId];
client.emit("disconnect");
// console.error("[vfs-collab] Client disconnected", Object.keys(clients).length);
}
client.on("error", function (err) {
onClose();
console.error("[vfs-collab] CLIENT SOCKET ERROR", err);
client.destroy();
});
});
return server;
}
function initSocket(userIds, callback) {
// var COLLAB_PORT = 33366;
// var COLLAB_HOST = process.env.OPENSHIFT_DIY_IP || "localhost";
var projectWD = getProjectWD();
var server;
var isServer = false;
// startServer();
// file sockets can have multiple servers open on the same path
// So, we connect first
var sockPath = process.platform == "win32"
? "\\\\.\\pipe\\"+ projectWD +"\\collab.sock"
: Path.join(projectWD, "collab.sock");
clientConnect();
function startServer() {
server = createServer();
console.error("[vfs-collab] PID:", PID, "Socket:", sockPath,
"ClinetId:", userIds.clientId, " & UserId:", userIds.userId);
async.series([
function (next) {
// Create the directoty ~/.c9 if not existing
Fs.mkdir(Path.dirname(projectWD), function (err) {
if (err && err.code !== "EEXIST")
return next(err);
next();
});
},
function (next) {
// Create the directoty ~/.c9/$pid if not existing
Fs.mkdir(projectWD, function (err) {
if (err && err.code !== "EEXIST")
return next(err);
next();
});
},
function (next) {
// Remove the stale socket, if existing at ~/.c9/$pid/collab.sock
Fs.unlink(sockPath, function (err) {
if (err && err.code !== "ENOENT")
return next(err);
next();
});
},
], function(err) {
if (err)
return callback(err);
function closeServerThenCallback(err) {
try {
console.error("[vfs-collab] Shuting down a faulty collab server - reason: ", err);
server.close();
} catch(e) {
console.error("[vfs-collab] Can't shutdown faulty collab server", e);
}
callback(err);
}
server.listen(sockPath, function () {
isServer = true;
server.collabInited = false;
// init server state
documents = {};
watchers = {};
clients = {};
// Check server installation, init the server and then connect the client to the inited collab server
installServer(function (err) {
if (err) return closeServerThenCallback(err);
initDB(false, function (err) {
if (err)
return closeServerThenCallback(err);
server.collabInited = true;
clientConnect();
server.emit("collabInited");
});
});
server.on("close", function () {
console.error("[vfs-collab] Server closed");
// Should handover to another server (if exists)
// e.g. Elect the first client as the new master.
});
});
server.on("error", function (err) {
// if another connection/thread was able to listen as collab-server, let's just connect to it
if (err.code === "EADDRINUSE")
return clientConnect();
console.error("[vfs-collab] Server error", err);
});
});
}
// Connect to a collab client
// If this fails to connect or the socket file doesn't exist, we try to create the server first
function clientConnect() {
var stream = new Stream();
stream.readable = true;
var client = net.connect(sockPath, function () {
client.setTimeout(0);
client.setNoDelay(true);
client.setKeepAlive(true);
client.userIds = userIds;
client.clientStream = stream;
// console.error("[vfs-collab] User connected:", userIds.clientId);
client.on("data", function handShake(data) {
// console.error("[vfs-collab]", "Client handshaked", data.toString());
client.removeListener("data", handShake);
client.on("data", onData);
});
var buff = [];
function onData(data) {
data = data.toString();
var idx;
while (true) {
idx = data.indexOf("\0\0");
if (idx === -1)
return buff.push(data);
buff.push(data.substring(0, idx));
var streamData = buff.join("");
data = data.substring(idx + 2);
buff = [];
stream.emit("data", streamData);
}
}
client.on("close", function() {
// console.error("[vfs-collab] Connection closed :", userIds.userId);
stream.emit("end");
});
client.write(JSON.stringify(userIds), "utf8", function() {
callback(null, client, isServer && server);
});
});
client.on("error", function (err) {
if (err && (err.code === "ECONNREFUSED" || err.code === "ENOENT")) {
startServer();
}
else {
console.error("[vfs-collab] CLIENT SOCK ERR", err, client.userIds);
// mock client.write
client.write = function () {
console.error("[vfs-collab] CLIENT SOCK WRITE AFTER ERROR", client.userIds);
console.trace();
};
stream.emit("end");
}
});
}
}
/**
* Export the vfs extend API hook
* Receive the user and project identification thorugh the vfs-extend server-verified options
*
* @param {Vfs} vfs - an instance of localfs.js
* @param {Object} options - { user: {}, project: {} }
* @param {Function} register - register the collab server API
*/
var exports = module.exports = function(vfs, options, register) {
var vfsClientMap = {};
var isMaster;
localfsAPI = vfs;
function connect(opts, callback) {
var user = options.user;
var project = options.project;
var clientId = opts.clientId;
if (!user || !project || !clientId || !opts.basePath)
return callback(new Error("[OT] Invalid or icomplete collab options passed: " + opts.basePath + " " + clientId ));
PID = project.pid || project.id;
basePath = Path.normalize(opts.basePath);
var userIds = {
userId: user.uid || user.id,
email: user.email,
fullname: user.fullname,
clientId: clientId,
fs: options.readonly ? "r" : "rw"
};
function cleanOldClient() {
if (!vfsClientMap[clientId])
return;
console.error("[vfs-collab] Disposing old client - possible reconnect?", clientId);
dispose(clientId);
}
cleanOldClient();
initSocket(userIds, function (err, client, server) {
if (err)
return callback(err.message ? err : new Error(err));
client.netServer = server;
cleanOldClient();
vfsClientMap[clientId] = client;
isMaster = !!server;
callback(null, {
stream: client.clientStream,
isMaster: isMaster
});
});
}
function send(clientId, msg) {
// console.error("[vfs-collab] IN-STREAM", msg);
var client = vfsClientMap[clientId];
if (client)
client.write(JSON.stringify(msg)+"\0\0");
}
function dispose(clientId) {
var client = vfsClientMap[clientId];
if (!client)
return;
client.end();
client.destroy();
// TODO: properly handover
// if (client.netServer)
// client.netServer.close();
delete vfsClientMap[clientId];
}
/**
* Get a `Document` from the database given its path
* @param {String} path the document path to query the database with
* @param [{String}] attributes - optional
* @param {Function} callback
* @param {Object} callback.err
* @param {Object} callback.result The result, or null if getDocument() failed (might even though err is null)
*/
function getDocument(path, attributes, callback) {
if (!Document) {
console.log("Initializing collab db for read access");
return initDB(true, getDocument.bind(null, path, attributes, callback));
}
Store.getDocument(path, attributes, callback);
}
register(null, {
connect: connect,
send: send,
dispose: dispose,
getDocument: getDocument,
emitter: emitter
});
};
// export for testing
exports.Store = Store;
exports.compressDocument = compressDocument;
/* Google diff match patch library: https://code.google.com/p/google-diff-match-patch/ */
var DIFF_EQUAL = 0;
var DIFF_INSERT = 1;
var DIFF_DELETE = -1;
function diff_match_patch(){this.Diff_Timeout=1;this.Diff_EditCost=4;this.Match_Threshold=0.5;this.Match_Distance=1E3;this.Patch_DeleteThreshold=0.5;this.Patch_Margin=4;this.Match_MaxBits=32}
diff_match_patch.prototype.diff_main=function(a,b,c,d){"undefined"==typeof d&&(d=0>=this.Diff_Timeout?Number.MAX_VALUE:(new Date).getTime()+1E3*this.Diff_Timeout);if(null==a||null==b)throw Error("Null input. (diff_main)");if(a==b)return a?[[0,a]]:[];"undefined"==typeof c&&(c=!0);var e=c,f=this.diff_commonPrefix(a,b),c=a.substring(0,f),a=a.substring(f),b=b.substring(f),f=this.diff_commonSuffix(a,b),g=a.substring(a.length-f),a=a.substring(0,a.length-f),b=b.substring(0,b.length-f),a=this.diff_compute_(a,b,e,d);c&&a.unshift([0,c]);g&&a.push([0,g]);this.diff_cleanupMerge(a);return a};
diff_match_patch.prototype.diff_compute_=function(a,b,c,d){if(!a)return[[1,b]];if(!b)return[[-1,a]];var e=a.length>b.length?a:b,f=a.length>b.length?b:a,g=e.indexOf(f);if(-1!=g)return c=[[1,e.substring(0,g)],[0,f],[1,e.substring(g+f.length)]],a.length>b.length&&(c[0][0]=c[2][0]=-1),c;if(1==f.length)return[[-1,a],[1,b]];return(e=this.diff_halfMatch_(a,b))?(f=e[0],a=e[1],g=e[2],b=e[3],e=e[4],f=this.diff_main(f,g,c,d),c=this.diff_main(a,b,c,d),f.concat([[0,e]],c)):c&&100<a.length&&100<b.length?this.diff_lineMode_(a,b,d):this.diff_bisect_(a,b,d)};
diff_match_patch.prototype.diff_lineMode_=function(a,b,c){var d=this.diff_linesToChars_(a,b),a=d.chars1,b=d.chars2,d=d.lineArray,a=this.diff_main(a,b,!1,c);this.diff_charsToLines_(a,d);this.diff_cleanupSemantic(a);a.push([0,""]);for(var e=d=b=0,f="",g="";b<a.length;){switch(a[b][0]){case 1:e++;g+=a[b][1];break;case -1:d++;f+=a[b][1];break;case 0:if(1<=d&&1<=e){a.splice(b-d-e,d+e);b=b-d-e;d=this.diff_main(f,g,!1,c);for(e=d.length-1;0<=e;e--)a.splice(b,0,d[e]);b+=d.length}d=e=0;g=f=""}b++}a.pop();return a};
diff_match_patch.prototype.diff_bisect_=function(a,b,c){for(var d=a.length,e=b.length,f=Math.ceil((d+e)/2),g=f,h=2*f,j=Array(h),i=Array(h),k=0;k<h;k++)j[k]=-1,i[k]=-1;j[g+1]=0;i[g+1]=0;for(var k=d-e,p=0!=k%2,q=0,s=0,o=0,v=0,u=0;u<f&&!((new Date).getTime()>c);u++){for(var n=-u+q;n<=u-s;n+=2){var l=g+n,m;m=n==-u||n!=u&&j[l-1]<j[l+1]?j[l+1]:j[l-1]+1;for(var r=m-n;m<d&&r<e&&a.charAt(m)==b.charAt(r);)m++,r++;j[l]=m;if(m>d)s+=2;else if(r>e)q+=2;else if(p&&(l=g+k-n,0<=l&&l<h&&-1!=i[l])){var t=d-i[l];if(m>=t)return this.diff_bisectSplit_(a,b,m,r,c)}}for(n=-u+o;n<=u-v;n+=2){l=g+n;t=n==-u||n!=u&&i[l-1]<i[l+1]?i[l+1]:i[l-1]+1;for(m=t-n;t<d&&m<e&&a.charAt(d-t-1)==b.charAt(e-m-1);)t++,m++;i[l]=t;if(t>d)v+=2;else if(m>e)o+=2;else if(!p&&(l=g+k-n,0<=l&&l<h&&-1!=j[l]&&(m=j[l],r=g+m-l,t=d-t,m>=t)))return this.diff_bisectSplit_(a,b,m,r,c)}}return[[-1,a],[1,b]]};
diff_match_patch.prototype.diff_bisectSplit_=function(a,b,c,d,e){var f=a.substring(0,c),g=b.substring(0,d),a=a.substring(c),b=b.substring(d),f=this.diff_main(f,g,!1,e),e=this.diff_main(a,b,!1,e);return f.concat(e)};
diff_match_patch.prototype.diff_linesToChars_=function(a,b){function c(a){for(var b="",c=0,f=-1,g=d.length;f<a.length-1;){f=a.indexOf("\n",c);-1==f&&(f=a.length-1);var q=a.substring(c,f+1),c=f+1;(e.hasOwnProperty?e.hasOwnProperty(q):void 0!==e[q])?b+=String.fromCharCode(e[q]):(b+=String.fromCharCode(g),e[q]=g,d[g++]=q)}return b}var d=[],e={};d[0]="";var f=c(a),g=c(b);return{chars1:f,chars2:g,lineArray:d}};
diff_match_patch.prototype.diff_charsToLines_=function(a,b){for(var c=0;c<a.length;c++){for(var d=a[c][1],e=[],f=0;f<d.length;f++)e[f]=b[d.charCodeAt(f)];a[c][1]=e.join("")}};diff_match_patch.prototype.diff_commonPrefix=function(a,b){if(!a||!b||a.charAt(0)!=b.charAt(0))return 0;for(var c=0,d=Math.min(a.length,b.length),e=d,f=0;c<e;)a.substring(f,e)==b.substring(f,e)?f=c=e:d=e,e=Math.floor((d-c)/2+c);return e};
diff_match_patch.prototype.diff_commonSuffix=function(a,b){if(!a||!b||a.charAt(a.length-1)!=b.charAt(b.length-1))return 0;for(var c=0,d=Math.min(a.length,b.length),e=d,f=0;c<e;)a.substring(a.length-e,a.length-f)==b.substring(b.length-e,b.length-f)?f=c=e:d=e,e=Math.floor((d-c)/2+c);return e};
diff_match_patch.prototype.diff_commonOverlap_=function(a,b){var c=a.length,d=b.length;if(0==c||0==d)return 0;c>d?a=a.substring(c-d):c<d&&(b=b.substring(0,c));c=Math.min(c,d);if(a==b)return c;for(var d=0,e=1;;){var f=a.substring(c-e),f=b.indexOf(f);if(-1==f)return d;e+=f;if(0==f||a.substring(c-e)==b.substring(0,e))d=e,e++}};
diff_match_patch.prototype.diff_halfMatch_=function(a,b){function c(a,b,c){for(var d=a.substring(c,c+Math.floor(a.length/4)),e=-1,g="",h,j,n,l;-1!=(e=b.indexOf(d,e+1));){var m=f.diff_commonPrefix(a.substring(c),b.substring(e)),r=f.diff_commonSuffix(a.substring(0,c),b.substring(0,e));g.length<r+m&&(g=b.substring(e-r,e)+b.substring(e,e+m),h=a.substring(0,c-r),j=a.substring(c+m),n=b.substring(0,e-r),l=b.substring(e+m))}return 2*g.length>=a.length?[h,j,n,l,g]:null}if(0>=this.Diff_Timeout)return null;var d=a.length>b.length?a:b,e=a.length>b.length?b:a;if(4>d.length||2*e.length<d.length)return null;var f=this,g=c(d,e,Math.ceil(d.length/4)),d=c(d,e,Math.ceil(d.length/2)),h;if(!g&&!d)return null;h=d?g?g[4].length>d[4].length?g:d:d:g;var j;a.length>b.length?(g=h[0],d=h[1],e=h[2],j=h[3]):(e=h[0],j=h[1],g=h[2],d=h[3]);h=h[4];return[g,d,e,j,h]};
diff_match_patch.prototype.diff_cleanupSemantic=function(a){for(var b=!1,c=[],d=0,e=null,f=0,g=0,h=0,j=0,i=0;f<a.length;)0==a[f][0]?(c[d++]=f,g=j,h=i,i=j=0,e=a[f][1]):(1==a[f][0]?j+=a[f][1].length:i+=a[f][1].length,e&&e.length<=Math.max(g,h)&&e.length<=Math.max(j,i)&&(a.splice(c[d-1],0,[-1,e]),a[c[d-1]+1][0]=1,d--,d--,f=0<d?c[d-1]:-1,i=j=h=g=0,e=null,b=!0)),f++;b&&this.diff_cleanupMerge(a);this.diff_cleanupSemanticLossless(a);for(f=1;f<a.length;){if(-1==a[f-1][0]&&1==a[f][0]){b=a[f-1][1];c=a[f][1];d=this.diff_commonOverlap_(b,c);e=this.diff_commonOverlap_(c,b);if(d>=e){if(d>=b.length/2||d>=c.length/2)a.splice(f,0,[0,c.substring(0,d)]),a[f-1][1]=b.substring(0,b.length-d),a[f+1][1]=c.substring(d),f++}else if(e>=b.length/2||e>=c.length/2)a.splice(f,0,[0,b.substring(0,e)]),a[f-1][0]=1,a[f-1][1]=c.substring(0,c.length-e),a[f+1][0]=-1,a[f+1][1]=b.substring(e),f++;f++}f++}};
diff_match_patch.prototype.diff_cleanupSemanticLossless=function(a){function b(a,b){if(!a||!b)return 6;var c=a.charAt(a.length-1),d=b.charAt(0),e=c.match(diff_match_patch.nonAlphaNumericRegex_),f=d.match(diff_match_patch.nonAlphaNumericRegex_),g=e&&c.match(diff_match_patch.whitespaceRegex_),h=f&&d.match(diff_match_patch.whitespaceRegex_),c=g&&c.match(diff_match_patch.linebreakRegex_),d=h&&d.match(diff_match_patch.linebreakRegex_),i=c&&a.match(diff_match_patch.blanklineEndRegex_),j=d&&b.match(diff_match_patch.blanklineStartRegex_);return i||j?5:c||d?4:e&&!g&&h?3:g||h?2:e||f?1:0}for(var c=1;c<a.length-1;){if(0==a[c-1][0]&&0==a[c+1][0]){var d=a[c-1][1],e=a[c][1],f=a[c+1][1],g=this.diff_commonSuffix(d,e);if(g)var h=e.substring(e.length-g),d=d.substring(0,d.length-g),e=h+e.substring(0,e.length-g),f=h+f;for(var g=d,h=e,j=f,i=b(d,e)+b(e,f);e.charAt(0)===f.charAt(0);){var d=d+e.charAt(0),e=e.substring(1)+f.charAt(0),f=f.substring(1),k=b(d,e)+b(e,f);k>=i&&(i=k,g=d,h=e,j=f)}a[c-1][1]!=g&&(g?a[c-1][1]=g:(a.splice(c-1,1),c--),a[c][1]=h,j?a[c+1][1]=j:(a.splice(c+1,1),c--))}c++}};diff_match_patch.nonAlphaNumericRegex_=/[^a-zA-Z0-9]/;diff_match_patch.whitespaceRegex_=/\s/;diff_match_patch.linebreakRegex_=/[\r\n]/;diff_match_patch.blanklineEndRegex_=/\n\r?\n$/;diff_match_patch.blanklineStartRegex_=/^\r?\n\r?\n/;
diff_match_patch.prototype.diff_cleanupEfficiency=function(a){for(var b=!1,c=[],d=0,e=null,f=0,g=!1,h=!1,j=!1,i=!1;f<a.length;){if(0==a[f][0])a[f][1].length<this.Diff_EditCost&&(j||i)?(c[d++]=f,g=j,h=i,e=a[f][1]):(d=0,e=null),j=i=!1;else if(-1==a[f][0]?i=!0:j=!0,e&&(g&&h&&j&&i||e.length<this.Diff_EditCost/2&&3==g+h+j+i))a.splice(c[d-1],0,[-1,e]),a[c[d-1]+1][0]=1,d--,e=null,g&&h?(j=i=!0,d=0):(d--,f=0<d?c[d-1]:-1,j=i=!1),b=!0;f++}b&&this.diff_cleanupMerge(a)};
diff_match_patch.prototype.diff_cleanupMerge=function(a){a.push([0,""]);for(var b=0,c=0,d=0,e="",f="",g;b<a.length;)switch(a[b][0]){case 1:d++;f+=a[b][1];b++;break;case -1:c++;e+=a[b][1];b++;break;case 0:1<c+d?(0!==c&&0!==d&&(g=this.diff_commonPrefix(f,e),0!==g&&(0<b-c-d&&0==a[b-c-d-1][0]?a[b-c-d-1][1]+=f.substring(0,g):(a.splice(0,0,[0,f.substring(0,g)]),b++),f=f.substring(g),e=e.substring(g)),g=this.diff_commonSuffix(f,e),0!==g&&(a[b][1]=f.substring(f.length-g)+a[b][1],f=f.substring(0,f.length-g),e=e.substring(0,e.length-g))),0===c?a.splice(b-d,c+d,[1,f]):0===d?a.splice(b-c,c+d,[-1,e]):a.splice(b-c-d,c+d,[-1,e],[1,f]),b=b-c-d+(c?1:0)+(d?1:0)+1):0!==b&&0==a[b-1][0]?(a[b-1][1]+=a[b][1],a.splice(b,1)):b++,c=d=0,f=e=""}""===a[a.length-1][1]&&a.pop();c=!1;for(b=1;b<a.length-1;)0==a[b-1][0]&&0==a[b+1][0]&&(a[b][1].substring(a[b][1].length-a[b-1][1].length)==a[b-1][1]?(a[b][1]=a[b-1][1]+a[b][1].substring(0,a[b][1].length-a[b-1][1].length),a[b+1][1]=a[b-1][1]+a[b+1][1],a.splice(b-1,1),c=!0):a[b][1].substring(0,a[b+1][1].length)==a[b+1][1]&&(a[b-1][1]+=a[b+1][1],a[b][1]=a[b][1].substring(a[b+1][1].length)+a[b+1][1],a.splice(b+1,1),c=!0)),b++;c&&this.diff_cleanupMerge(a)};
diff_match_patch.prototype.diff_xIndex=function(a,b){var c=0,d=0,e=0,f=0,g;for(g=0;g<a.length;g++){1!==a[g][0]&&(c+=a[g][1].length);-1!==a[g][0]&&(d+=a[g][1].length);if(c>b)break;e=c;f=d}return a.length!=g&&-1===a[g][0]?f:f+(b-e)};
diff_match_patch.prototype.diff_prettyHtml=function(a){for(var b=[],c=/&/g,d=/</g,e=/>/g,f=/\n/g,g=0;g<a.length;g++){var h=a[g][0],j=a[g][1],j=j.replace(c,"&amp;").replace(d,"&lt;").replace(e,"&gt;").replace(f,"&para;<br>");switch(h){case 1:b[g]='<ins style="background:#e6ffe6;">'+j+"</ins>";break;case -1:b[g]='<del style="background:#ffe6e6;">'+j+"</del>";break;case 0:b[g]="<span>"+j+"</span>"}}return b.join("")};
diff_match_patch.prototype.diff_text1=function(a){for(var b=[],c=0;c<a.length;c++)1!==a[c][0]&&(b[c]=a[c][1]);return b.join("")};
diff_match_patch.prototype.diff_text2=function(a){for(var b=[],c=0;c<a.length;c++)-1!==a[c][0]&&(b[c]=a[c][1]);return b.join("")};
diff_match_patch.prototype.diff_levenshtein=function(a){for(var b=0,c=0,d=0,e=0;e<a.length;e++){var f=a[e][0],g=a[e][1];switch(f){case 1:c+=g.length;break;case -1:d+=g.length;break;case 0:b+=Math.max(c,d),d=c=0}}return b+=Math.max(c,d)};
diff_match_patch.prototype.diff_toDelta=function(a){for(var b=[],c=0;c<a.length;c++)switch(a[c][0]){case 1:b[c]="+"+encodeURI(a[c][1]);break;case -1:b[c]="-"+a[c][1].length;break;case 0:b[c]="="+a[c][1].length}return b.join("\t").replace(/%20/g," ")};
diff_match_patch.prototype.diff_fromDelta=function(a,b){for(var c=[],d=0,e=0,f=b.split(/\t/g),g=0;g<f.length;g++){var h=f[g].substring(1);switch(f[g].charAt(0)){case "+":try{c[d++]=[1,decodeURI(h)]}catch(j){throw Error("Illegal escape in diff_fromDelta: "+h);}break;case "-":case "=":var i=parseInt(h,10);if(isNaN(i)||0>i)throw Error("Invalid number in diff_fromDelta: "+h);h=a.substring(e,e+=i);"="==f[g].charAt(0)?c[d++]=[0,h]:c[d++]=[-1,h];break;default:if(f[g])throw Error("Invalid diff operation in diff_fromDelta: "+f[g]);}}if(e!=a.length)throw Error("Delta length ("+e+") does not equal source text length ("+a.length+").");return c};
diff_match_patch.prototype.match_main=function(a,b,c){if(null==a||null==b||null==c)throw Error("Null input. (match_main)");c=Math.max(0,Math.min(c,a.length));return a==b?0:a.length?a.substring(c,c+b.length)==b?c:this.match_bitap_(a,b,c):-1};
diff_match_patch.prototype.match_bitap_=function(a,b,c){function d(a,d){var e=a/b.length,g=Math.abs(c-d);return!f.Match_Distance?g?1:e:e+g/f.Match_Distance}if(b.length>this.Match_MaxBits)throw Error("Pattern too long for this browser.");var e=this.match_alphabet_(b),f=this,g=this.Match_Threshold,h=a.indexOf(b,c);-1!=h&&(g=Math.min(d(0,h),g),h=a.lastIndexOf(b,c+b.length),-1!=h&&(g=Math.min(d(0,h),g)));for(var j=1<<b.length-1,h=-1,i,k,p=b.length+a.length,q,s=0;s<b.length;s++){i=0;for(k=p;i<k;)d(s,c+k)<=g?i=k:p=k,k=Math.floor((p-i)/2+i);p=k;i=Math.max(1,c-k+1);var o=Math.min(c+k,a.length)+b.length;k=Array(o+2);for(k[o+1]=(1<<s)-1;o>=i;o--){var v=e[a.charAt(o-1)];k[o]=0===s?(k[o+1]<<1|1)&v:(k[o+1]<<1|1)&v|(q[o+1]|q[o])<<1|1|q[o+1];if(k[o]&j&&(v=d(s,o-1),v<=g))if(g=v,h=o-1,h>c)i=Math.max(1,2*c-h);else break}if(d(s+1,c)>g)break;q=k}return h};
diff_match_patch.prototype.match_alphabet_=function(a){for(var b={},c=0;c<a.length;c++)b[a.charAt(c)]=0;for(c=0;c<a.length;c++)b[a.charAt(c)]|=1<<a.length-c-1;return b};
diff_match_patch.prototype.patch_addContext_=function(a,b){if(0!=b.length){for(var c=b.substring(a.start2,a.start2+a.length1),d=0;b.indexOf(c)!=b.lastIndexOf(c)&&c.length<this.Match_MaxBits-this.Patch_Margin-this.Patch_Margin;)d+=this.Patch_Margin,c=b.substring(a.start2-d,a.start2+a.length1+d);d+=this.Patch_Margin;(c=b.substring(a.start2-d,a.start2))&&a.diffs.unshift([0,c]);(d=b.substring(a.start2+a.length1,a.start2+a.length1+d))&&a.diffs.push([0,d]);a.start1-=c.length;a.start2-=c.length;a.length1+=c.length+d.length;a.length2+=c.length+d.length}};
diff_match_patch.prototype.patch_make=function(a,b,c){var d;if("string"==typeof a&&"string"==typeof b&&"undefined"==typeof c)d=a,b=this.diff_main(d,b,!0),2<b.length&&(this.diff_cleanupSemantic(b),this.diff_cleanupEfficiency(b));else if(a&&"object"==typeof a&&"undefined"==typeof b&&"undefined"==typeof c)b=a,d=this.diff_text1(b);else if("string"==typeof a&&b&&"object"==typeof b&&"undefined"==typeof c)d=a;else if("string"==typeof a&&"string"==typeof b&&c&&"object"==typeof c)d=a,b=c;else throw Error("Unknown call format to patch_make.");if(0===b.length)return[];for(var c=[],a=new diff_match_patch.patch_obj,e=0,f=0,g=0,h=d,j=0;j<b.length;j++){var i=b[j][0],k=b[j][1];if(!e&&0!==i)a.start1=f,a.start2=g;switch(i){case 1:a.diffs[e++]=b[j];a.length2+=k.length;d=d.substring(0,g)+k+d.substring(g);break;case -1:a.length1+=k.length;a.diffs[e++]=b[j];d=d.substring(0,g)+d.substring(g+k.length);break;case 0:k.length<=2*this.Patch_Margin&&e&&b.length!=j+1?(a.diffs[e++]=b[j],a.length1+=k.length,a.length2+=k.length):k.length>=2*this.Patch_Margin&&e&&(this.patch_addContext_(a,h),c.push(a),a=new diff_match_patch.patch_obj,e=0,h=d,f=g)}1!==i&&(f+=k.length);-1!==i&&(g+=k.length)}e&&(this.patch_addContext_(a,h),c.push(a));return c};
diff_match_patch.prototype.patch_deepCopy=function(a){for(var b=[],c=0;c<a.length;c++){var d=a[c],e=new diff_match_patch.patch_obj;e.diffs=[];for(var f=0;f<d.diffs.length;f++)e.diffs[f]=d.diffs[f].slice();e.start1=d.start1;e.start2=d.start2;e.length1=d.length1;e.length2=d.length2;b[c]=e}return b};
diff_match_patch.prototype.patch_apply=function(a,b){if(0==a.length)return[b,[]];var a=this.patch_deepCopy(a),c=this.patch_addPadding(a),b=c+b+c;this.patch_splitMax(a);for(var d=0,e=[],f=0;f<a.length;f++){var g=a[f].start2+d,h=this.diff_text1(a[f].diffs),j,i=-1;if(h.length>this.Match_MaxBits){if(j=this.match_main(b,h.substring(0,this.Match_MaxBits),g),-1!=j&&(i=this.match_main(b,h.substring(h.length-this.Match_MaxBits),g+h.length-this.Match_MaxBits),-1==i||j>=i))j=-1}else j=this.match_main(b,h,g);if(-1==j)e[f]=!1,d-=a[f].length2-a[f].length1;else if(e[f]=!0,d=j-g,g=-1==i?b.substring(j,j+h.length):b.substring(j,i+this.Match_MaxBits),h==g)b=b.substring(0,j)+this.diff_text2(a[f].diffs)+b.substring(j+h.length);else if(g=this.diff_main(h,g,!1),h.length>this.Match_MaxBits&&this.diff_levenshtein(g)/h.length>this.Patch_DeleteThreshold)e[f]=!1;else{this.diff_cleanupSemanticLossless(g);for(var h=0,k,i=0;i<a[f].diffs.length;i++){var p=a[f].diffs[i];0!==p[0]&&(k=this.diff_xIndex(g,h));1===p[0]?b=b.substring(0,j+k)+p[1]+b.substring(j+k):-1===p[0]&&(b=b.substring(0,j+k)+b.substring(j+this.diff_xIndex(g,h+p[1].length)));-1!==p[0]&&(h+=p[1].length)}}}b=b.substring(c.length,b.length-c.length);return[b,e]};
diff_match_patch.prototype.patch_addPadding=function(a){for(var b=this.Patch_Margin,c="",d=1;d<=b;d++)c+=String.fromCharCode(d);for(d=0;d<a.length;d++)a[d].start1+=b,a[d].start2+=b;var d=a[0],e=d.diffs;if(0==e.length||0!=e[0][0])e.unshift([0,c]),d.start1-=b,d.start2-=b,d.length1+=b,d.length2+=b;else if(b>e[0][1].length){var f=b-e[0][1].length;e[0][1]=c.substring(e[0][1].length)+e[0][1];d.start1-=f;d.start2-=f;d.length1+=f;d.length2+=f}d=a[a.length-1];e=d.diffs;0==e.length||0!=e[e.length-1][0]?(e.push([0,c]),d.length1+=b,d.length2+=b):b>e[e.length-1][1].length&&(f=b-e[e.length-1][1].length,e[e.length-1][1]+=c.substring(0,f),d.length1+=f,d.length2+=f);return c};
diff_match_patch.prototype.patch_splitMax=function(a){for(var b=this.Match_MaxBits,c=0;c<a.length;c++)if(!(a[c].length1<=b)){var d=a[c];a.splice(c--,1);for(var e=d.start1,f=d.start2,g="";0!==d.diffs.length;){var h=new diff_match_patch.patch_obj,j=!0;h.start1=e-g.length;h.start2=f-g.length;if(""!==g)h.length1=h.length2=g.length,h.diffs.push([0,g]);for(;0!==d.diffs.length&&h.length1<b-this.Patch_Margin;){var g=d.diffs[0][0],i=d.diffs[0][1];1===g?(h.length2+=i.length,f+=i.length,h.diffs.push(d.diffs.shift()),j=!1):-1===g&&1==h.diffs.length&&0==h.diffs[0][0]&&i.length>2*b?(h.length1+=i.length,e+=i.length,j=!1,h.diffs.push([g,i]),d.diffs.shift()):(i=i.substring(0,b-h.length1-this.Patch_Margin),h.length1+=i.length,e+=i.length,0===g?(h.length2+=i.length,f+=i.length):j=!1,h.diffs.push([g,i]),i==d.diffs[0][1]?d.diffs.shift():d.diffs[0][1]=d.diffs[0][1].substring(i.length))}g=this.diff_text2(h.diffs);g=g.substring(g.length-this.Patch_Margin);i=this.diff_text1(d.diffs).substring(0,this.Patch_Margin);""!==i&&(h.length1+=i.length,h.length2+=i.length,0!==h.diffs.length&&0===h.diffs[h.diffs.length-1][0]?h.diffs[h.diffs.length-1][1]+=i:h.diffs.push([0,i]));j||a.splice(++c,0,h)}}};
diff_match_patch.prototype.patch_toText=function(a){for(var b=[],c=0;c<a.length;c++)b[c]=a[c];return b.join("")};
diff_match_patch.prototype.patch_fromText=function(a){var b=[];if(!a)return b;for(var a=a.split("\n"),c=0,d=/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@$/;c<a.length;){var e=a[c].match(d);if(!e)throw Error("Invalid patch string: "+a[c]);var f=new diff_match_patch.patch_obj;b.push(f);f.start1=parseInt(e[1],10);""===e[2]?(f.start1--,f.length1=1):"0"==e[2]?f.length1=0:(f.start1--,f.length1=parseInt(e[2],10));f.start2=parseInt(e[3],10);""===e[4]?(f.start2--,f.length2=1):"0"==e[4]?f.length2=0:(f.start2--,f.length2=parseInt(e[4],10));for(c++;c<a.length;){e=a[c].charAt(0);try{var g=decodeURI(a[c].substring(1))}catch(h){throw Error("Illegal escape in patch_fromText: "+g);}if("-"==e)f.diffs.push([-1,g]);else if("+"==e)f.diffs.push([1,g]);else if(" "==e)f.diffs.push([0,g]);else if("@"==e)break;else if(""!==e)throw Error('Invalid patch mode "'+e+'" in: '+g);c++}}return b};
diff_match_patch.patch_obj=function(){this.diffs=[];this.start2=this.start1=null;this.length2=this.length1=0};
diff_match_patch.patch_obj.prototype.toString=function(){var a,b;a=0===this.length1?this.start1+",0":1==this.length1?this.start1+1:this.start1+1+","+this.length1;b=0===this.length2?this.start2+",0":1==this.length2?this.start2+1:this.start2+1+","+this.length2;a=["@@ -"+a+" +"+b+" @@\n"];var c;for(b=0;b<this.diffs.length;b++){switch(this.diffs[b][0]){case 1:c="+";break;case -1:c="-";break;case 0:c=" "}a[b+1]=c+encodeURI(this.diffs[b][1])+"\n"}return a.join("").replace(/%20/g," ")};
/* diff match patch end */
// Copied from async
// Can be generically used in many scenarios
var async = function() {
function forEachSeries(arr, iterator, callback) {
callback = callback || function () {};
if (!arr.length) {
return callback();
}
var completed = 0;
var iterate = function () {
iterator(arr[completed], function (err) {
if (err) {
callback(err);
callback = function () {};
}
else {
completed += 1;
if (completed === arr.length) {
callback(null);
}
else {
iterate();
}
}
});
};
iterate();
}
function series(arr, callback) {
forEachSeries(arr, function (fn, next) {
fn.call(null, function (err) {
if (err)
return callback(err);
next();
});
}, callback);
}
return {
series: series,
forEachSeries: forEachSeries
};
}();
// Copied from https://github.com/gjtorikian/isBinaryFile
function isBinaryFile(file, callback) {
var max_bytes = 512;
exists(file, function (exists) {
if (!exists)
return callback(null, false);
Fs.open(file, 'r', function(err, descriptor) {
if (err)
return callback(err);
var bytes = new Buffer(max_bytes);
// Read the file with no encoding for raw buffer access.
Fs.read(descriptor, bytes, 0, bytes.length, 0, function(err, size, bytes) {
Fs.close(descriptor, function(err2) {
if (err || err2)
return callback(err || err2);
return callback(null, isBinaryCheck(size, bytes));
});
});
});
});
function isBinaryCheck(size, bytes) {
if (size === 0)
return false;
var suspicious_bytes = 0;
var total_bytes = Math.min(size, max_bytes);
if (size >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) {
// UTF-8 BOM. This isn't binary.
return false;
}
for (var i = 0; i < total_bytes; i++) {
if (bytes[i] === 0) { // NULL byte--it's binary!
return true;
}
else if ((bytes[i] < 7 || bytes[i] > 14) && (bytes[i] < 32 || bytes[i] > 127)) {
// UTF-8 detection
if (bytes[i] > 191 && bytes[i] < 224 && i + 1 < total_bytes) {
i++;
if (bytes[i] < 192) {
continue;
}
}
else if (bytes[i] > 223 && bytes[i] < 239 && i + 2 < total_bytes) {
i++;
if (bytes[i] < 192 && bytes[i + 1] < 192) {
i++;
continue;
}
}
suspicious_bytes++;
// Read at least 32 bytes before making a decision
if (i > 32 && (suspicious_bytes * 100) / total_bytes > 10) {
return true;
}
}
}
if ((suspicious_bytes * 100) / total_bytes > 10) {
return true;
}
return false;
}
}
function isVeryLargeFile(file, contents, callback) {
Fs.stat(file, function(err, stat) {
if (err) return callback(err);
callback(null, stat.size > 1024 * 1024 || contents && contents.length > 1024 * 1024);
});
}
/*
// Quick testing:
basePath = __dirname;
dbFilePath = __dirname + "/test.db";
// dbFilePath = "/home/ubuntu/newclient/corrupted_collab.db";
initDB(false, function(err){
if (err)
return console.error("initDB error:", err);
console.error("DB inited");
Store.newDocument({
path: "test.txt",
contents: Fs.readFileSync(__dirname + "/../template.js", "utf8")
}, function (err) {
if (err)
return console.error(err);
console.error("Test document created");
Store.getDocument("test.txt", function (err, doc) {
console.error("ERR1", err);
// console.error(JSON.stringify(doc));
Store.getWorkspaceState(function (err, ws) {
console.log("ERR2:", err);
});
});
});
});
lock("abc", function () {
console.log("first locking");
setTimeout(function () {
unlock("abc");
}, 100);
});
lock("abc", function () {
console.log("second locking");
setTimeout(function () {
unlock("abc");
}, 100);
});
*/