diff --git a/www/json/api.js b/www/json/api.js index 20ace5914..bb03835a0 100644 --- a/www/json/api.js +++ b/www/json/api.js @@ -3,20 +3,18 @@ define([ '/api/config?cb=' + Math.random().toString(16).substring(2), '/common/crypto.js', '/common/realtime-input.js', - '/json/listmap.js', '/common/json-ot.js', 'json.sortify', '/bower_components/textpatcher/TextPatcher.amd.js', + '/json/deep-proxy.js', '/bower_components/jquery/dist/jquery.min.js', -], function (Config, Crypto, Realtime, ListMap, JsonOT, Sortify, TextPatcher) { +], function (Config, Crypto, Realtime, JsonOT, Sortify, TextPatcher, DeepProxy) { var api = {}; - api.ListMap = ListMap; - var create = api.create = function (cfg) { /* validate your inputs before proceeding */ - if (['object', 'array'].indexOf(ListMap.type(cfg.data))) { + if (['object', 'array'].indexOf(DeepProxy.type(cfg.data))) { throw new Error('unsupported datatype'); } @@ -28,12 +26,34 @@ define([ cryptKey: cfg.cryptKey, crypto: Crypto, websocketURL: Config.websocketURL, + logLevel: 0 }; var rt; - var proxy = ListMap.makeProxy(cfg.data); var realtime; + var proxy; + + var onLocal = config.onLocal = function () { + var strung = Sortify(proxy); + + realtime.patchText(strung); + + // try harder + if (realtime.getUserDoc() !== strung) { + realtime.patchText(strung); + } + + // onLocal + if (cfg.onLocal) { + cfg.onLocal(); + } + + // TODO actually emit 'change' events, or something like them + }; + + proxy = DeepProxy.create(cfg.data, onLocal, true); + var onInit = config.onInit = function (info) { realtime = info.realtime; // create your patcher @@ -50,39 +70,18 @@ define([ var userDoc = realtime.getUserDoc(); var parsed = JSON.parse(userDoc); - // update your proxy to the state of the userDoc - Object.keys(parsed).forEach(function (key) { - proxy[key] = ListMap.recursiveProxies(parsed[key]); - }); + DeepProxy.update(proxy, parsed); // onReady cfg.onReady(info); }; - // FIXME - var onLocal = config.onLocal = ListMap.onLocal = function () { - var strung = Sortify(proxy); - - realtime.patchText(strung); - - // try harder - if (realtime.getUserDoc() !== strung) { - realtime.patchText(strung); - } - - // onLocal - if (cfg.onLocal) { - cfg.onLocal(); - } - - // TODO actually emit 'change' events, or something like them - }; var onRemote = config.onRemote = function (info) { var userDoc = realtime.getUserDoc(); var parsed = JSON.parse(userDoc); - ListMap.update(proxy, parsed); + DeepProxy.update(proxy, parsed, onLocal); // onRemote if (cfg.onRemote) { diff --git a/www/json/deep-proxy.js b/www/json/deep-proxy.js new file mode 100644 index 000000000..ebc21bb65 --- /dev/null +++ b/www/json/deep-proxy.js @@ -0,0 +1,435 @@ +define([ + '/bower_components/proxy-polyfill/proxy.min.js', // https://github.com/GoogleChrome/proxy-polyfill +], function () { + // linter complains if this isn't defined + var Proxy = window.Proxy; + + var deepProxy = {}; + + var isArray = deepProxy.isArray = function (obj) { + return Object.prototype.toString.call(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); + }; + + /* 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') { + return; + throw new Error("'on' is a reserved attribute name for realtime lists and maps"); + } + if (obj[prop] === value) { return value; } + + var t_value = type(value); + if (['array', 'object'].indexOf(t_value) !== -1) { + //console.log("Constructing new proxy for value with type [%s]", t_value); + var proxy = obj[prop] = deepProxy.create(value, cb); + } else { + //console.log("Setting [%s] to [%s]", prop, value); + obj[prop] = value; + } + + cb(); + return obj[prop]; + }; + }; + + var pathMatches = deepProxy.pathMatches = function (path, pattern) { + console.log("Comparing checking if path:[%s] matches pattern:[%s]", path.join(','), pattern.join(',')); + return !pattern.some(function (x, i) { + return x !== path[i]; + }); + }; + + var getter = deepProxy.get = function (cb) { + var events = { + disconnect: [], + change: [], + ready: [], + remove: [], + }; + + var on = function (evt, pattern, f) { + switch (evt) { + case 'change': + console.log("[MOCK] adding change listener at path [%s]", pattern.join(',')); + events.change.push(function (oldval, newval, path, root) { + if (pathMatches(path, pattern)) { + f(oldval, newval, path, root); + } else { + console.log("path did not match pattern!"); + } + }); + break; + case 'ready': + break; + case 'disconnect': + break; + case 'delete': + break; + default: + break; + } + return true; + }; + + return function (obj, prop) { + if (prop === 'on') { + return on; + } else if (prop === '_events') { + return events; + } + + // FIXME magic? + if (prop === 'length' && typeof(obj.length) === 'number') { return obj.length; } + + return obj[prop]; + }; + }; + + var handlers = deepProxy.handlers = function (cb) { + return { + set: setter(cb), + get: getter(cb), + }; + }; + + var create = deepProxy.create = function (obj, opt, root) { + var methods = type(opt) === 'function'? handlers(opt) : opt; + return new Proxy(obj, methods); + }; + + // onChange(path, key, root, oldval, newval) + var onChange = function (path, key, root, oldval, newval) { + var P = path.slice(0); + P.push(key); + console.log('change at path [%s]', P.join(',')); + + /* TODO make this such that we can halt propogation to less specific + paths? */ + root._events.change.forEach(function (f, i) { + f(oldval, newval, P, root); + }); + }; + + // newval doesn't really make sense here + var onRemove = function (path, key, root, oldval, newval) { + console.log("onRemove is stubbed for now"); + return false; + }; + + /* 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) + */ + + var hasChanged = false; + + Bkeys.forEach(function (b) { + //console.log(b); + var t_b = type(B[b]); + var old = A[b]; + + if (Akeys.indexOf(b) === -1) { + // there was an insertion + //console.log("Inserting new key: [%s]", b); + + // 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 + hasChanged = true; + + // 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': + // deletions are a removal + //delete A[b]; + //onRemove(path, b, root, old, undefined); + + // this should never happen? + throw new Error("first pass should never reveal undefined keys"); + //break; + case 'array': + //console.log('construct list'); + A[b] = f(B[b]); + // make a new proxy + break; + case 'object': + //console.log('construct map'); + 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]; + + hasChanged = true; + 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 + + if (objects.call(root, A[b], B[b], f, nextPath, root)) { + hasChanged = true; + // TODO do you want to call onChange when an object changes? + //onChange(path, b, root, old, B[b]); + } + } else { + // it's an array + if (deepProxy.arrays.call(root, A[b], B[b], f, nextPath, root)) { + hasChanged = true; + + // TODO do you want to call onChange when an object changes? + //onChange(path, b, root, old, B[b]); + } + } + }); + Akeys.forEach(function (a) { + var old = A[a]; + + if (Bkeys.indexOf(a) === -1 || type(B[a]) === 'undefined') { + //console.log("Deleting [%s]", a); + // the key was deleted! + delete A[a]; + + onRemove(path, a, root, old, B[a]); + } + }); + + return hasChanged; + }; + + var arrays = deepProxy.arrays = function (A, B, f, path, root) { + var l_A = A.length; + var l_B = B.length; + + var hasChanged = false; + + 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 = B[i]; + + if (t_a !== t_b) { + // type changes are always destructive + // that's good news because destructive is easy + switch (t_b) { + case 'object': + A[i] = f(b); + break; + case 'array': + A[i] = f(b); + break; + default: + A[i] = b; + break; + } + + hasChanged = true; + // 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': + if (objects.call(root, A[i], b, f, nextPath, root)) { + hasChanged = true; + onChange(path, i, root, old, b); + } + break; + case 'array': + if (arrays.call(root, A[i], b, f, nextPath, root)) { + hasChanged = true; + onChange(path, i, root, old, b); + } + break; + default: + if (b !== A[i]) { + A[i] = b; + onChange(path, i, root, old, b); + hasChanged = true; + } + break; + } + } + }); + + + if (l_A > l_B) { + // A was longer than B, so there have been deletions + var i = l_B; + var t_a; + + for (; i < l_B; i++) { + // it was most definitely a deletion + onRemove(path, i, root, A[i], undefined); + } + // 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) { + // watch out for fallthrough behaviour + switch (t_b) { + case 'object': + case 'array': + A[i] = f(B[i]); + break; + default: + A[i] = B[i]; + break; + } + + hasChanged = true; + 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 'object': + if (objects.call(root, A[i], B[i], f, nextPath, root)) { + hasChanged = true; + onChange(path, i, root, old, B[i]); + } + break; + case 'array': + if (arrays.call(root, A[i], B[i], f, nextPath, root)) { + hasChanged = true; + onChange(path, i, root, old, B[i]); + } + break; + default: + if (A[i] !== B[i]) { + A[i] = B[i]; + hasChanged = true; + onChange(path, i, root, old, B[i]); + } + break; + } + }); + return hasChanged; + }; + + var update = 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"); + } + }; + + return deepProxy; +}); diff --git a/www/json/listmap.js b/www/json/listmap.js deleted file mode 100644 index 998469931..000000000 --- a/www/json/listmap.js +++ /dev/null @@ -1,323 +0,0 @@ -define([ - '/bower_components/proxy-polyfill/proxy.min.js', // https://github.com/GoogleChrome/proxy-polyfill -],function () { - var Proxy = window.Proxy; - - var ListMap = {}; - - var isArray = ListMap.isArray = function (obj) { - return Object.prototype.toString.call(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 = ListMap.type = function (dat) { - return dat === null? 'null': isArray(dat)?'array': typeof(dat); - }; - - var makeHandlers = function (cb) { - return { - get: function (obj, prop) { - // FIXME magic? - if (prop === 'length' && typeof(obj.length) === 'number') { return obj.length; } - - return obj[prop]; - }, - set: function (obj, prop, value) { - if (prop === 'on') { - throw new Error("'on' is a reserved attribute name for realtime lists and maps"); - } - if (obj[prop] === value) { return value; } - - var t_value = ListMap.type(value); - if (['array', 'object'].indexOf(t_value) !== -1) { - console.log("Constructing new proxy for value with type [%s]", t_value); - var proxy = obj[prop] = ListMap.makeProxy(value); - } else { - console.log("Setting [%s] to [%s]", prop, value); - obj[prop] = value; - } - - cb(); - return obj[prop]; - }, - }; - }; - - var handlers = ListMap.handlers = { - get: function (obj, prop) { - // FIXME magic? - if (prop === 'length' && typeof(obj.length) === 'number') { return obj.length; } - - return obj[prop]; - }, - set: function (obj, prop, value) { - if (prop === 'on') { - throw new Error("'on' is a reserved attribute name for realtime lists and maps"); - } - if (obj[prop] === value) { return value; } - - var t_value = ListMap.type(value); - if (['array', 'object'].indexOf(t_value) !== -1) { - console.log("Constructing new proxy for value with type [%s]", t_value); - var proxy = obj[prop] = ListMap.makeProxy(value); - } else { - console.log("Setting [%s] to [%s]", prop, value); - obj[prop] = value; - } - - // FIXME this is NO GOOD - ListMap.onLocal(); - return obj[prop]; - } - }; - - var makeProxy = ListMap.makeProxy = function (obj, local) { - local = local || ListMap.onLocal; - - return new Proxy(obj, handlers); //makeHandlers(ListMap.onLocal)); - }; - - var recursiveProxies = ListMap.recursiveProxies = function (obj) { - var t_obj = type(obj); - - var proxy; - - switch (t_obj) { - case 'object': - proxy = makeProxy({}); - ListMap.objects(proxy, obj, makeProxy, []); - return proxy; - case 'array': - proxy = makeProxy([]); - ListMap.arrays(proxy, obj, makeProxy, []); - return proxy; - default: - return obj; - } - }; - - var onChange = function (path, key) { - var P = path.slice(0); - P.push(key); - console.log('change at path [%s]', P.join(',')); - }; - - /* ListMap objects A and B, where A is the _older_ of the two */ - ListMap.objects = function (A, B, f, path) { - var Akeys = Object.keys(A); - var Bkeys = Object.keys(B); - - //console.log("inspecting path [%s]", path.join(',')); - - /* 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 */ - Bkeys.forEach(function (b) { - //console.log(b); - var t_b = type(B[b]); - - if (Akeys.indexOf(b) === -1) { - // there was an insertion - console.log("Inserting new key: [%s]", b); - onChange(path, b); - - switch (t_b) { - case 'undefined': - // umm. this should never happen? - throw new Error("undefined type has key. this shouldn't happen?"); - //break; - case 'array': - //console.log('construct list'); - A[b] = f(B[b]); - break; - case 'object': - //console.log('construct map'); - A[b] = f(B[b]); - break; - default: - A[b] = B[b]; - break; - } - } 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': - delete A[b]; - break; - case 'array': - //console.log('construct list'); - A[b] = f(B[b]); - // make a new proxy - break; - case 'object': - //console.log('construct map'); - A[b] = f(B[b]); - // make a new proxy - break; - default: - // all other datatypes just require assignment. - A[b] = B[b]; - break; - } - } else { - // did values change? - - if (['array', 'object'].indexOf(t_a) === -1) { - // we can do deep equality... - if (A[b] !== B[b]) { - onChange(path, b); - console.log("changed values from [%s] to [%s]", A[b], B[b]); - A[b] = B[b]; - } - } else { - var nextPath = path.slice(0); - nextPath.push(b); - if (t_a === 'object') { - // it's an object - ListMap.objects(A[b], B[b], f, nextPath); - } else { - // it's an array - ListMap.arrays(A[b], B[b], f, nextPath); - } - } - } - } - }); - Akeys.forEach(function (a) { - if (Bkeys.indexOf(a) === -1 || type(B[a]) === 'undefined') { - onChange(path, a); - console.log("Deleting [%s]", a); - // the key was deleted! - delete A[a]; - } - }); - }; - - ListMap.arrays = function (A, B, f, path) { - var l_A = A.length; - var l_B = B.length; - - // TODO do things with the path (callbacks) - - 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); - - if (t_a !== t_b) { - // type changes are always destructive - // that's good news because destructive is easy - switch (t_b) { - case 'object': - A[i] = f(b); - break; - case 'array': - A[i] = f(b); - break; - default: - A[i] = b; - break; - } - } else { - // same type - var nextPath = path.slice(0); - nextPath.push(i); - - switch (t_b) { - case 'object': - ListMap.objects(A[i], b, f, nextPath); - break; - case 'array': - ListMap.arrays(A[i], b, f, nextPath); - break; - default: - onChange(path, i); - A[i] = b; - break; - } - } - }); - return; - } else { - // they are the same length... - A.forEach(function (a, i) { - var t_a = type(a); - var t_b = type(B[i]); - - if (t_a !== t_b) { - switch (t_b) { - case 'object': - A[i] = f(B[i]); - break; - case 'array': - A[i] = f(B[i]); - break; - default: - A[i] = B[i]; - break; - } - return; - } else { - var nextPath = path.slice(0); - nextPath.push(i); - - // same type - switch (t_b) { - case 'object': - ListMap.objects(A[i], B[i], f, nextPath); - break; - case 'array': - ListMap.arrays(A[i], B[i], f, nextPath); - break; - default: - A[i] = B[i]; - break; - } - } - }); - } - }; - - var update = ListMap.update = function (A, B) { - - 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) { - case 'array': - ListMap.arrays(A, B, function (obj) { - return makeProxy(obj); - }); - // idk - break; - case 'object': - ListMap.objects(A, B, function (obj) { - //console.log("constructing new proxy for type [%s]", type(obj)); - return makeProxy(obj); - }, []); - break; - default: - throw new Error("unsupported realtime datatype"); - } - }; - - return ListMap; -});