From 4af01e6e9e58e9cdc80509b8c80a5c48a9fb63e1 Mon Sep 17 00:00:00 2001 From: ansuz Date: Tue, 19 Sep 2017 14:16:13 +0200 Subject: [PATCH] make listmap consume cpNfInner --- www/common/sframe-chainpad-listmap.js | 762 ++++++++++++++++++++++++++ 1 file changed, 762 insertions(+) create mode 100644 www/common/sframe-chainpad-listmap.js diff --git a/www/common/sframe-chainpad-listmap.js b/www/common/sframe-chainpad-listmap.js new file mode 100644 index 000000000..bb5506af0 --- /dev/null +++ b/www/common/sframe-chainpad-listmap.js @@ -0,0 +1,762 @@ +require.config({ paths: { 'json.sortify': '/bower_components/json.sortify/dist/JSON.sortify' } }); +define([ + '/bower_components/chainpad-netflux/chainpad-netflux.js', + '/bower_components/chainpad-json-validator/json-ot.js', + 'json.sortify', + '/bower_components/textpatcher/TextPatcher.js', +], function (Realtime, JsonOT, Sortify, TextPatcher) { + var api = {}; + // "Proxy" is undefined in Safari : we need to use an normal object and check if there are local + // changes regurlarly. + var isFakeProxy = typeof window.Proxy === "undefined"; + + var DeepProxy = api.DeepProxy = (function () { + var deepProxy = {}; + + var isArray = deepProxy.isArray = Array.isArray || function (obj) { + return Object.toString(obj) === '[object Array]'; + }; + + /* Arrays and nulls both register as 'object' when using native typeof + we need to distinguish them as their own types, so use this instead. */ + var type = deepProxy.type = function (dat) { + return dat === null? 'null': isArray(dat)?'array': typeof(dat); + }; + + /* Check if an (sub-)element in an object or an array and should be a proxy. + If the browser doesn't support Proxy, return false */ + var isProxyable = deepProxy.isProxyable = function (obj, forceCheck) { + if (typeof forceCheck === "undefined" && isFakeProxy) { return false; } + return ['object', 'array'].indexOf(type(obj)) !== -1; + }; + + /* Any time you set a value, check its type. + If that type is proxyable, make a new proxy. */ + var setter = deepProxy.set = function (cb) { + return function (obj, prop, value) { + if (prop === 'on') { + throw new Error("'on' is a reserved attribute name for realtime lists and maps"); + } + if (isProxyable(value)) { + obj[prop] = deepProxy.create(value, cb); + } else { + obj[prop] = value; + } + + cb(); + return obj[prop] || true; // always return truthey or you have problems + }; + }; + + var pathMatches = deepProxy.pathMatches = function (path, pattern) { + return !pattern.some(function (x, i) { + return x !== path[i]; + }); + }; + + var lengthDescending = function (a, b) { return b.pattern.length - a.pattern.length; }; + + /* TODO implement 'off' as well. + change 'setter' to warn users when they attempt to set 'off' + */ + var on = function(events) { + return function (evt, pattern, f) { + switch (evt) { + case 'change': + // pattern needs to be an array + pattern = type(pattern) === 'array'? pattern: [pattern]; + + events.change.push({ + cb: function (oldval, newval, path, root) { + if (pathMatches(path, pattern)) { + return f(oldval, newval, path, root); + } + }, + pattern: pattern, + }); + // sort into descending order so we evaluate in order of specificity + events.change.sort(lengthDescending); + + break; + case 'remove': + pattern = type(pattern) === 'array'? pattern: [pattern]; + + events.remove.push({ + cb: function (oldval, path, root) { + if (pathMatches(path, pattern)) { return f(oldval, path, root); } + }, + pattern: pattern, + }); + + events.remove.sort(lengthDescending); + + break; + case 'ready': + events.ready.push({ + // on('ready' has a different signature than + // change and delete, so use 'pattern', not 'f' + + cb: function (info) { + pattern(info); + } + }); + break; + case 'disconnect': + events.disconnect.push({ + cb: function (info) { + // as above + pattern(info); + } + }); + break; + case 'reconnect': + events.reconnect.push({ + cb: function (info) { + // as above + pattern(info); + } + }); + break; + case 'create': + events.create.push({ + cb: function (info) { + pattern(info); + } + }); + break; + default: + break; + } + return this; + }; + }; + + var getter = deepProxy.get = function (/* cb */) { + var events = { + disconnect: [], + reconnect: [], + change: [], + ready: [], + remove: [], + create: [], + }; + + return function (obj, prop) { + if (prop === 'on') { + return on(events); + } else if (prop === '_isProxy') { + return true; + } else if (prop === '_events') { + return events; + } + return obj[prop]; + }; + }; + + var deleter = deepProxy.delete = function (cb) { + return function (obj, prop) { + if (typeof(obj[prop]) === 'undefined') { return true; } + delete obj[prop]; + cb(); + return true; + }; + }; + + var handlers = deepProxy.handlers = function (cb, isRoot) { + if (!isRoot) { + return { + set: setter(cb), + get: function (obj, prop) { + if (prop === '_isProxy') { + return true; + } + return obj[prop]; + }, + deleteProperty: deleter(cb), + }; + } + return { + set: setter(cb), + get: getter(cb), + deleteProperty: deleter(cb), + }; + }; + + var remoteChangeFlag = deepProxy.remoteChangeFlag = false; + + var stringifyFakeProxy = deepProxy.stringifyFakeProxy = function (proxy) { + var copy = JSON.parse(Sortify(proxy)); + delete copy._events; + delete copy._isProxy; + return Sortify(copy); + }; + + deepProxy.checkLocalChange = function (obj, cb) { + if (!isFakeProxy) { return; } + var oldObj = stringifyFakeProxy(obj); + window.setInterval(function() { + var newObj = stringifyFakeProxy(obj); + if (newObj !== oldObj) { + oldObj = newObj; + if (remoteChangeFlag) { + remoteChangeFlag = false; + } else { + cb(); + } + } + },300); + }; + + var create = deepProxy.create = function (obj, opt, isRoot) { + /* recursively create proxies in case users do: + `x.a = {b: {c: 5}}; + + otherwise the inner object is not a proxy, which leads to incorrect + behaviour on the client that initiated the object (but not for + clients that receive the objects) */ + + // if the user supplied a callback, use it to create handlers + // this saves a bit of work in recursion + var methods = type(opt) === 'function'? handlers(opt, isRoot) : opt; + switch (type(obj)) { + case 'object': + var keys = Object.keys(obj); + keys.forEach(function (k) { + if (isProxyable(obj[k]) && !obj[k]._isProxy) { + obj[k] = create(obj[k], opt); + } + }); + break; + case 'array': + obj.forEach(function (o, i) { + if (isProxyable(o) && !o._isProxy) { + obj[i] = create(obj[i], opt); + } + }); + break; + default: + // if it's not an array or object, you don't need to proxy it + throw new Error('attempted to make a proxy of an unproxyable object'); + } + if (!isFakeProxy) { + if (obj._isProxy) { + return obj; + } + return new window.Proxy(obj, methods); + } + + var proxy = JSON.parse(JSON.stringify(obj)); + + if (isRoot) { + var events = { + disconnect: [], + reconnect: [], + change: [], + ready: [], + remove: [], + create: [], + }; + proxy.on = on(events); + proxy._events = events; + } + return proxy; + }; + + // onChange(path, key, root, oldval, newval) + var onChange = function (path, key, root, oldval, newval) { + var P = path.slice(0); + P.push(key); + + /* returning false in your callback terminates 'bubbling up' + we can accomplish this with Array.some because we've presorted + listeners by the specificity of their path + */ + root._events.change.some(function (handler) { + return handler.cb(oldval, newval, P, root) === false; + }); + }; + + var find = deepProxy.find = function (map, path) { + /* safely search for nested values in an object via a path */ + return (map && path.reduce(function (p, n) { + return typeof p[n] !== 'undefined' && p[n]; + }, map)) || undefined; + }; + + var onRemove = function (path, key, root, old, top) { + var newpath = path.concat(key); + var X = find(root, newpath); + + var t_X = type(X); + + /* TODO 'find' is correct but unnecessarily expensive. + optimize it. */ + + switch (t_X) { + case 'array': + + if (top) { + // the top of an onremove should emit an onchange instead + onChange(path, key, root, old, undefined);// no newval since it's a deletion + } else { + root._events.remove.forEach(function (handler) { + return handler.cb(X, newpath, root); + }); + } + // remove all of the array's children + X.forEach(function (x, i) { + onRemove(newpath, i, root); + }); + + break; + case 'object': + if (top) { + onChange(path, key, root, old, undefined);// no newval since it's a deletion + } else { + root._events.remove.forEach(function (handler) { + return handler.cb(X, newpath, root, old, false); + }); + } + // remove all of the object's children + Object.keys(X).forEach(function (key) { + onRemove(newpath, key, root, X[key], false); + }); + + break; + default: + root._events.remove.forEach(function (handler) { + return handler.cb(X, newpath, root); + }); + break; + } + }; + + /* compare a new object 'B' against an existing proxy object 'A' + provide a unary function 'f' for the purpose of constructing new + deep proxies from regular objects and arrays. + + Supply the path as you recurse, for the purpose of emitting events + attached to particular paths within the complete structure. + + Operates entirely via side effects on 'A' + */ + var objects = deepProxy.objects = function (A, B, f, path, root) { + var Akeys = Object.keys(A); + var Bkeys = Object.keys(B); + + /* iterating over the keys in B will tell you if a new key exists + it will not tell you if a key has been removed. + to accomplish that you will need to iterate over A's keys + */ + + /* TODO return a truthy or falsey value (in 'objects' and 'arrays') + so that we have some measure of whether an object or array changed + (from the higher level in the tree, rather than doing everything + at the leaf level). + + bonus points if you can defer events until the complete diff has + finished (collect them into an array or something, and simplify + the event if possible) + */ + + Bkeys.forEach(function (b) { + var t_b = type(B[b]); + var old = A[b]; + + if (Akeys.indexOf(b) === -1) { + // there was an insertion + + // mind the fallthrough behaviour + switch (t_b) { + case 'undefined': + // umm. this should never happen? + throw new Error("undefined type has key. this shouldn't happen?"); + case 'array': + case 'object': + A[b] = f(B[b]); + break; + default: + A[b] = B[b]; + } + + // insertions are a change + + // onChange(path, key, root, oldval, newval) + onChange(path, b, root, old, B[b]); + return; + } + + // else the key already existed + var t_a = type(A[b]); + if (t_a !== t_b) { + // its type changed! + console.log("type changed from [%s] to [%s]", t_a, t_b); + switch (t_b) { + case 'undefined': + throw new Error("first pass should never reveal undefined keys"); + case 'array': + A[b] = f(B[b]); + // make a new proxy + break; + case 'object': + A[b] = f(B[b]); + // make a new proxy + break; + default: + // all other datatypes just require assignment. + A[b] = B[b]; + break; + } + + // type changes always mean a change happened + onChange(path, b, root, old, B[b]); + return; + } + + // values might have changed, if not types + if (['array', 'object'].indexOf(t_a) === -1) { + // it's not an array or object, so we can do deep equality + if (A[b] !== B[b]) { + // not equal, so assign + A[b] = B[b]; + + onChange(path, b, root, old, B[b]); + } + return; + } + + // else it's an array or object + var nextPath = path.slice(0).concat(b); + if (t_a === 'object') { + // it's an object + objects.call(root, A[b], B[b], f, nextPath, root); + } else { + // it's an array + deepProxy.arrays.call(root, A[b], B[b], f, nextPath, root); + } + }); + Akeys.forEach(function (a) { + var old = A[a]; + + if (a === "on" || a === "_events") { return; } + + // the key was deleted + if (Bkeys.indexOf(a) === -1 || type(B[a]) === 'undefined') { + onRemove(path, a, root, old, true); + delete A[a]; + } + }); + + return; + }; + + var arrays = deepProxy.arrays = function (A, B, f, path, root) { + var l_A = A.length; + var l_B = B.length; + + if (l_A !== l_B) { + // B is longer than Aj + // there has been an insertion + + // OR + + // A is longer than B + // there has been a deletion + + B.forEach(function (b, i) { + var t_a = type(A[i]); + var t_b = type(b); + + var old = A[i]; + + if (t_a !== t_b) { + // type changes are always destructive + // that's good news because destructive is easy + switch (t_b) { + case 'undefined': + throw new Error('this should never happen'); + case 'object': + A[i] = f(b); + break; + case 'array': + A[i] = f(b); + break; + default: + A[i] = b; + break; + } + + // path, key, root object, oldvalue, newvalue + onChange(path, i, root, old, b); + } else { + // same type + var nextPath = path.slice(0).concat(i); + + switch (t_b) { + case 'object': + objects.call(root, A[i], b, f, nextPath, root); + break; + case 'array': + if (arrays.call(root, A[i], b, f, nextPath, root)) { + onChange(path, i, root, old, b); + } + break; + default: + if (b !== A[i]) { + A[i] = b; + onChange(path, i, root, old, b); + } + break; + } + } + }); + + if (l_A > l_B) { + // A was longer than B, so there have been deletions + var i = l_B; + //var t_a; + var old; + + for (; i <= l_B; i++) { + // recursively delete + old = A[i]; + + onRemove(path, i, root, old, true); + } + // cool + } + + A.length = l_B; + return; + } + + // else they are the same length, iterate over their values + A.forEach(function (a, i) { + var t_a = type(a); + var t_b = type(B[i]); + + var old = a; + + // they have different types + if (t_a !== t_b) { + switch (t_b) { + case 'undefined': + onRemove(path, i, root, old, true); + break; + + // watch out for fallthrough behaviour + // if it's an object or array, create a proxy + case 'object': + case 'array': + A[i] = f(B[i]); + break; + default: + A[i] = B[i]; + break; + } + + onChange(path, i, root, old, B[i]); + return; + } + + // they are the same type, clone the paths array and push to it + var nextPath = path.slice(0).concat(i); + + // same type + switch (t_b) { + case 'undefined': + throw new Error('existing key had type `undefined`. this should never happen'); + case 'object': + if (objects.call(root, A[i], B[i], f, nextPath, root)) { + onChange(path, i, root, old, B[i]); + } + break; + case 'array': + if (arrays.call(root, A[i], B[i], f, nextPath, root)) { + onChange(path, i, root, old, B[i]); + } + break; + default: + if (A[i] !== B[i]) { + A[i] = B[i]; + onChange(path, i, root, old, B[i]); + } + break; + } + }); + return; + }; + + deepProxy.update = function (A, B, cb) { + var t_A = type(A); + var t_B = type(B); + + if (t_A !== t_B) { + throw new Error("Proxy updates can't result in type changes"); + } + + switch (t_B) { + /* use .call so you can supply a different `this` value */ + case 'array': + arrays.call(A, A, B, function (obj) { + return create(obj, cb); + }, [], A); + break; + case 'object': + // arrays.call(this, A , B , f, path , root) + objects.call(A, A, B, function (obj) { + return create(obj, cb); + }, [], A); + break; + default: + throw new Error("unsupported realtime datatype:" + t_B); + } + }; + + return deepProxy; + }()); + + api.create = function (cfg) { + /* validate your inputs before proceeding */ + + if (!DeepProxy.isProxyable(cfg.data, true)) { + throw new Error('unsupported datatype: '+ DeepProxy.type(cfg.data)); + } + + if (!cfg.crypto) { + // complain and stub + console.log("[chainpad-listmap] no crypto module provided. messages will not be encrypted"); + cfg.crypto = { + encrypt: function (msg) { + return msg; + }, + decrypt: function (msg) { + return msg; + } + }; + } + + var realtimeOptions = { + userName: cfg.userName, + initialState: Sortify(cfg.data), + readOnly: cfg.readOnly, + transformFunction: JsonOT.transform || JsonOT.validate, + logLevel: typeof(cfg.logLevel) === 'undefined'? 0: cfg.logLevel, + validateContent: function (content) { + try { + JSON.parse(content); + return true; + } catch (e) { + console.error("Failed to parse, rejecting patch"); + return false; + } + }, + }; + + var initializing = true; + + var setterCb = function () { + if (!DeepProxy.remoteChangeFlag) { realtimeOptions.onLocal(); } + }; + + var rt = {}; + var realtime; + + var proxy; + var patchText; + + realtimeOptions.onRemote = function () { + if (initializing) { return; } + // TODO maybe implement history support here + + var userDoc = realtime.getUserDoc(); + var parsed = JSON.parse(userDoc); + + DeepProxy.remoteChangeFlag = true; + DeepProxy.update(proxy, parsed, setterCb); + DeepProxy.remoteChangeFlag = false; + }; + + var onLocal = realtimeOptions.onLocal = function () { + if (initializing) { return; } + if (readOnly) { return; } + var strung = isFakeProxy? DeepProxy.stringifyFakeProxy(proxy): Sortify(proxy); + patchText(strung); + + // try harder + if (realtime.getUserDoc() !== strung) { patchText(strung); } + + // onLocal + if (cfg.onLocal) { cfg.onLocal(); } + }; + + proxy = DeepProxy.create(cfg.data, setterCb, true); + console.log(proxy); + + realtimeOptions.onInit = function (info) { + proxy._events.create.forEach(function (handler) { + handler.cb(info); + }); + }; + + realtimeOptions.onReady = function (info) { + // create your patcher + if (realtime !== info.realtime) { + realtime = rt.realtime = info.realtime; + patchText = TextPatcher.create({ + realtime: realtime, + logging: cfg.logging || false, + }); + } else { + console.error(realtime); + } + + var userDoc = realtime.getUserDoc(); + var parsed = JSON.parse(userDoc); + + DeepProxy.update(proxy, parsed, setterCb); + + proxy._events.ready.forEach(function (handler) { + handler.cb(info); + }); + + DeepProxy.checkLocalChange(proxy, onLocal); + + initializing = false; + }; + + realtimeOptions.onAbort = function (info) { + proxy._events.disconnect.forEach(function (handler) { + handler.cb(info); + }); + }; + + realtimeOptions.onConnectionChange = function (info) { + if (info.state) { // reconnect + initializing = true; + proxy._events.reconnect.forEach(function (handler) { + handler.cb(info); + }); + return; + } + // disconnected + proxy._events.disconnect.forEach(function (handler) { + handler.cb(info); + }); + }; + + realtimeOptions.onError = function (info) { + proxy._events.disconnect.forEach(function (handler) { + handler.cb(info); + }); + }; + + realtime = rt.cpCnInner = cfg.common.startRealtime(realtimeOptions); + rt.proxy = proxy; + rt.realtime = realtime; + + return rt; + }; + + return api; +});