// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.easysync1 /** * Copyright 2009 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS-IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ function Changeset(arg) { var array; if ((typeof arg) == "string") { // constant array = [Changeset.MAGIC, 0, arg.length, 0, 0, arg]; } else if ((typeof arg) == "number") { var n = Math.round(arg); // delete-all on n-length text (useful for making a "builder") array = [Changeset.MAGIC, n, 0, 0, 0, ""]; } else if (! arg) { // identity on 0-length text array = [Changeset.MAGIC, 0, 0, 0, 0, ""]; } else if (arg.isChangeset) { return arg; } else array = arg; array.isChangeset = true; // OOP style: attach generic methods to array object, hold no state in environment //function error(msg) { top.console.error(msg); top.console.trace(); } function error(msg) { var e = new Error(msg); e.easysync = true; throw e; } function assert(b, msg) { if (! b) error("Changeset: "+String(msg)); } function min(x, y) { return (x < y) ? x : y; } Changeset._assert = assert; array.isIdentity = function() { return this.length == 6 && this[1] == this[2] && this[3] == 0 && this[4] == this[1] && this[5] == ""; } array.eachStrip = function(func, thisObj) { // inside "func", the method receiver will be "this" by default, // or you can pass an object. for(var i=0;i= 0, "bad old text length"); assert(this[2] >= 0, "bad new text length"); assert((this.length % 3) == 0, "bad array length"); assert(this.length >= 6, "must be at least one strip"); var numStrips = this.numStrips(); var oldLen = this[1]; var newLen = this[2]; // iterate over the "text strips" var actualNewLen = 0; this.eachStrip(function(startIndex, numTaken, newText, i) { var s = startIndex, t = numTaken, n = newText; var isFirst = (i == 0); var isLast = (i == numStrips-1); assert(t >= 0, "can't take negative number of chars"); assert(isFirst || t > 0, "all strips but first must take"); assert((t > 0) || (s == 0), "if first strip doesn't take, must have 0 startIndex"); assert(s >= 0 && s + t <= oldLen, "bad index: "+this.toString()); assert(t > 0 || n.length > 0 || (isFirst && isLast), "empty strip must be first and only"); if (! isLast) { var s2 = this[3 + i*3 + 3]; // startIndex of following strip var gap = s2 - (s + t); assert(gap >= 0, "overlapping or out-of-order strips: "+this.toString()); assert(gap > 0 || n.length > 0, "touching strips with no added text"); } actualNewLen += t + n.length; }); assert(newLen == actualNewLen, "calculated new text length doesn't match"); } array.applyToText = function(text) { assert(text.length == this.oldLen(), "mismatched apply: "+text.length+" / "+this.oldLen()); var buf = []; this.eachStrip(function (s, t, n) { buf.push(text.substr(s, t), n); }); return buf.join(''); } function _makeBuilder(oldLen, supportAuthors) { var C = Changeset(oldLen); if (supportAuthors) { _ensureAuthors(C); } return C.builder(); } function _getNumInserted(C) { var numChars = 0; C.eachStrip(function(s,t,n) { numChars += n.length; }); return numChars; } function _ensureAuthors(C) { if (! C.authors) { C.setAuthor(); } return C; } array.setAuthor = function(author) { var C = this; // authors array has even length >= 2; // alternates [numChars1, author1, numChars2, author2]; // all numChars > 0 unless there is exactly one, in which // case it can be == 0. C.authors = [_getNumInserted(C), author || '']; return C; } array.builder = function() { // normal pattern is Changeset(oldLength).builder().appendOldText(...). ... // builder methods mutate this! var C = this; // OOP style: state in environment var self; return self = { appendNewText: function(str, author) { C[C.length-1] += str; C[2] += str.length; if (C.authors) { var a = (author || ''); var lastAuthorPtr = C.authors.length-1; var lastAuthorLengthPtr = C.authors.length-2; if ((!a) || a == C.authors[lastAuthorPtr]) { C.authors[lastAuthorLengthPtr] += str.length; } else if (0 == C.authors[lastAuthorLengthPtr]) { C.authors[lastAuthorLengthPtr] = str.length; C.authors[lastAuthorPtr] = (a || C.authors[lastAuthorPtr]); } else { C.authors.push(str.length, a); } } return self; }, appendOldText: function(startIndex, numTaken) { if (numTaken == 0) return self; // properties of last strip... var s = C[C.length-3], t = C[C.length-2], n = C[C.length-1]; if (t == 0 && n == "") { // must be empty changeset, one strip that doesn't take old chars or add new ones C[C.length-3] = startIndex; C[C.length-2] = numTaken; } else if (n == "" && (s+t == startIndex)) { C[C.length-2] += numTaken; // take more } else C.push(startIndex, numTaken, ""); // add a strip C[2] += numTaken; C.checkRep(); return self; }, toChangeset: function() { return C; } }; } array.authorSlicer = function(outputBuilder) { return _makeAuthorSlicer(this, outputBuilder); } function _makeAuthorSlicer(changesetOrAuthorsIn, builderOut) { // "builderOut" only needs to support appendNewText var authors; // considered immutable if (changesetOrAuthorsIn.isChangeset) { authors = changesetOrAuthorsIn.authors; } else { authors = changesetOrAuthorsIn; } // OOP style: state in environment var authorPtr = 0; var charIndex = 0; var charWithinAuthor = 0; // 0 <= charWithinAuthor <= authors[authorPtr]; max value iff atEnd var atEnd = false; function curAuthor() { return authors[authorPtr+1]; } function curAuthorWidth() { return authors[authorPtr]; } function assertNotAtEnd() { assert(! atEnd, "_authorSlicer: can't move past end"); } function forwardInAuthor(numChars) { charWithinAuthor += numChars; charIndex += numChars; } function nextAuthor() { assertNotAtEnd(); assert(charWithinAuthor == curAuthorWidth(), "_authorSlicer: not at author end"); charWithinAuthor = 0; authorPtr += 2; if (authorPtr == authors.length) { atEnd = true; } } var self; return self = { skipChars: function(n) { assert(n >= 0, "_authorSlicer: can't skip negative n"); if (n == 0) return; assertNotAtEnd(); var leftToSkip = n; while (leftToSkip > 0) { var leftInAuthor = curAuthorWidth() - charWithinAuthor; if (leftToSkip >= leftInAuthor) { forwardInAuthor(leftInAuthor); leftToSkip -= leftInAuthor; nextAuthor(); } else { forwardInAuthor(leftToSkip); leftToSkip = 0; } } }, takeChars: function(n, text) { assert(n >= 0, "_authorSlicer: can't take negative n"); if (n == 0) return; assertNotAtEnd(); assert(n == text.length, "_authorSlicer: bad text length"); var textLeft = text; var leftToTake = n; while (leftToTake > 0) { if (curAuthorWidth() > 0 && charWithinAuthor < curAuthorWidth()) { // at least one char to take from current author var leftInAuthor = (curAuthorWidth() - charWithinAuthor); assert(leftInAuthor > 0, "_authorSlicer: should have leftInAuthor > 0"); var toTake = min(leftInAuthor, leftToTake); assert(toTake > 0, "_authorSlicer: should have toTake > 0"); builderOut.appendNewText(textLeft.substring(0, toTake), curAuthor()); forwardInAuthor(toTake); leftToTake -= toTake; textLeft = textLeft.substring(toTake); } assert(charWithinAuthor <= curAuthorWidth(), "_authorSlicer: past end of author"); if (charWithinAuthor == curAuthorWidth()) { nextAuthor(); } } }, setBuilder: function(builder) { builderOut = builder; } }; } function _makeSlicer(C, output) { // C: Changeset, output: builder from _makeBuilder // C is considered immutable, won't change or be changed // OOP style: state in environment var charIndex = 0; // 0 <= charIndex <= C.newLen(); maximum value iff atEnd var stripIndex = 0; // 0 <= stripIndex <= C.numStrips(); maximum value iff atEnd var charWithinStrip = 0; // 0 <= charWithinStrip < curStripWidth() var atEnd = false; var authorSlicer; if (C.authors) { authorSlicer = _makeAuthorSlicer(C.authors, output); } var ptr = 3; function curStartIndex() { return C[ptr]; } function curNumTaken() { return C[ptr+1]; } function curNewText() { return C[ptr+2]; } function curStripWidth() { return curNumTaken() + curNewText().length; } function assertNotAtEnd() { assert(! atEnd, "_slicer: can't move past changeset end"); } function forwardInStrip(numChars) { charWithinStrip += numChars; charIndex += numChars; } function nextStrip() { assertNotAtEnd(); assert(charWithinStrip == curStripWidth(), "_slicer: not at strip end"); charWithinStrip = 0; stripIndex++; ptr += 3; if (stripIndex == C.numStrips()) { atEnd = true; } } function curNumNewCharsInRange(start, end) { // takes two indices into the current strip's combined "taken" and "new" // chars, and returns how many "new" chars are included in the range assert(start <= end, "_slicer: curNumNewCharsInRange given out-of-order indices"); var nt = curNumTaken(); var nn = curNewText().length; var s = nt; var e = nt+nn; if (s < start) s = start; if (e > end) e = end; if (e < s) return 0; return e-s; } var self; return self = { skipChars: function (n) { assert(n >= 0, "_slicer: can't skip negative n"); if (n == 0) return; assertNotAtEnd(); var leftToSkip = n; while (leftToSkip > 0) { var leftInStrip = curStripWidth() - charWithinStrip; if (leftToSkip >= leftInStrip) { forwardInStrip(leftInStrip); if (authorSlicer) authorSlicer.skipChars(curNumNewCharsInRange(charWithinStrip, charWithinStrip + leftInStrip)); leftToSkip -= leftInStrip; nextStrip(); } else { if (authorSlicer) authorSlicer.skipChars(curNumNewCharsInRange(charWithinStrip, charWithinStrip + leftToSkip)); forwardInStrip(leftToSkip); leftToSkip = 0; } } }, takeChars: function (n) { assert(n >= 0, "_slicer: can't take negative n"); if (n == 0) return; assertNotAtEnd(); var leftToTake = n; while (leftToTake > 0) { if (curNumTaken() > 0 && charWithinStrip < curNumTaken()) { // at least one char to take from current strip's numTaken var leftInTaken = (curNumTaken() - charWithinStrip); assert(leftInTaken > 0, "_slicer: should have leftInTaken > 0"); var toTake = min(leftInTaken, leftToTake); assert(toTake > 0, "_slicer: should have toTake > 0"); output.appendOldText(curStartIndex() + charWithinStrip, toTake); forwardInStrip(toTake); leftToTake -= toTake; } if (leftToTake > 0 && curNewText().length > 0 && charWithinStrip >= curNumTaken() && charWithinStrip < curStripWidth()) { // at least one char to take from current strip's newText var leftInNewText = (curStripWidth() - charWithinStrip); assert(leftInNewText > 0, "_slicer: should have leftInNewText > 0"); var toTake = min(leftInNewText, leftToTake); assert(toTake > 0, "_slicer: should have toTake > 0"); var newText = curNewText().substr(charWithinStrip - curNumTaken(), toTake); if (authorSlicer) { authorSlicer.takeChars(newText.length, newText); } else { output.appendNewText(newText); } forwardInStrip(toTake); leftToTake -= toTake; } assert(charWithinStrip <= curStripWidth(), "_slicer: past end of strip"); if (charWithinStrip == curStripWidth()) { nextStrip(); } } }, skipTo: function(n) { self.skipChars(n - charIndex); } }; } array.slicer = function(outputBuilder) { return _makeSlicer(this, outputBuilder); } array.compose = function(next) { assert(next.oldLen() == this.newLen(), "mismatched composition"); var builder = _makeBuilder(this.oldLen(), !!(this.authors || next.authors)); var slicer = _makeSlicer(this, builder); var authorSlicer; if (next.authors) { authorSlicer = _makeAuthorSlicer(next.authors, builder); } next.eachStrip(function(s, t, n) { slicer.skipTo(s); slicer.takeChars(t); if (authorSlicer) { authorSlicer.takeChars(n.length, n); } else { builder.appendNewText(n); } }, this); return builder.toChangeset(); }; array.traverser = function() { return _makeTraverser(this); } function _makeTraverser(C) { var s = C[3], t = C[4], n = C[5]; var nextIndex = 6; var indexIntoNewText = 0; var authorSlicer; if (C.authors) { authorSlicer = _makeAuthorSlicer(C.authors, null); } function advanceIfPossible() { if (t == 0 && n == "" && nextIndex < C.length) { s = C[nextIndex]; t = C[nextIndex+1]; n = C[nextIndex+2]; nextIndex += 3; } } var self; return self = { numTakenChars: function() { // if starts with taken characters, then how many, else 0 return (t > 0) ? t : 0; }, numNewChars: function() { // if starts with new characters, then how many, else 0 return (t == 0 && n.length > 0) ? n.length : 0; }, takenCharsStart: function() { return (self.numTakenChars() > 0) ? s : 0; }, hasMore: function() { return self.numTakenChars() > 0 || self.numNewChars() > 0; }, curIndex: function() { return indexIntoNewText; }, consumeTakenChars: function (x) { assert(self.numTakenChars() > 0, "_traverser: no taken chars"); assert(x >= 0 && x <= self.numTakenChars(), "_traverser: bad number of taken chars"); if (x == 0) return; if (t == x) { s = 0; t = 0; } else { s += x; t -= x; } indexIntoNewText += x; advanceIfPossible(); }, consumeNewChars: function(x) { return self.appendNewChars(x, null); }, appendNewChars: function(x, builder) { assert(self.numNewChars() > 0, "_traverser: no new chars"); assert(x >= 0 && x <= self.numNewChars(), "_traverser: bad number of new chars"); if (x == 0) return ""; var str = n.substring(0, x); n = n.substring(x); indexIntoNewText += x; advanceIfPossible(); if (builder) { if (authorSlicer) { authorSlicer.setBuilder(builder); authorSlicer.takeChars(x, str); } else { builder.appendNewText(str); } } else { if (authorSlicer) authorSlicer.skipChars(x); return str; } }, consumeAvailableTakenChars: function() { return self.consumeTakenChars(self.numTakenChars()); }, consumeAvailableNewChars: function() { return self.consumeNewChars(self.numNewChars()); }, appendAvailableNewChars: function(builder) { return self.appendNewChars(self.numNewChars(), builder); } }; } array.follow = function(prev, reverseInsertOrder) { // prev: Changeset, reverseInsertOrder: boolean // A.compose(B.follow(A)) is the merging of Changesets A and B, which operate on the same old text. // It is always the same as B.compose(A.follow(B, true)). assert(prev.oldLen() == this.oldLen(), "mismatched follow: "+prev.oldLen()+"/"+this.oldLen()); var builder = _makeBuilder(prev.newLen(), !! this.authors); var a = _makeTraverser(prev); var b = _makeTraverser(this); while (a.hasMore() || b.hasMore()) { if (a.numNewChars() > 0 && ! reverseInsertOrder) { builder.appendOldText(a.curIndex(), a.numNewChars()); a.consumeAvailableNewChars(); } else if (b.numNewChars() > 0) { b.appendAvailableNewChars(builder); } else if (a.numNewChars() > 0 && reverseInsertOrder) { builder.appendOldText(a.curIndex(), a.numNewChars()); a.consumeAvailableNewChars(); } else if (! b.hasMore()) a.consumeAvailableTakenChars(); else if (! a.hasMore()) b.consumeAvailableTakenChars(); else { var x = a.takenCharsStart(); var y = b.takenCharsStart(); if (x < y) a.consumeTakenChars(min(a.numTakenChars(), y-x)); else if (y < x) b.consumeTakenChars(min(b.numTakenChars(), x-y)); else { var takenByBoth = min(a.numTakenChars(), b.numTakenChars()); builder.appendOldText(a.curIndex(), takenByBoth); a.consumeTakenChars(takenByBoth); b.consumeTakenChars(takenByBoth); } } } return builder.toChangeset(); } array.encodeToString = function(asBinary) { var stringDataArray = []; var numsArray = []; if (! asBinary) numsArray.push(this[0]); numsArray.push(this[1], this[2]); this.eachStrip(function(s, t, n) { numsArray.push(s, t, n.length); stringDataArray.push(n); }, this); if (! asBinary) { return numsArray.join(',')+'|'+stringDataArray.join(''); } else { return "A" + Changeset.numberArrayToString(numsArray) +escapeCrazyUnicode(stringDataArray.join('')); } } function escapeCrazyUnicode(str) { return str.replace(/\\/g, '\\\\').replace(/[\ud800-\udfff]/g, function (c) { return "\\u"+("0000"+c.charCodeAt(0).toString(16)).slice(-4); }); } array.applyToAttributedText = Changeset.applyToAttributedText; function splicesFromChanges(c) { var splices = []; // get a list of splices, [startChar, endChar, newText] var traverser = c.traverser(); var oldTextLength = c.oldLen(); var indexIntoOldText = 0; while (traverser.hasMore() || indexIntoOldText < oldTextLength) { var newText = ""; var startChar = indexIntoOldText; var endChar = indexIntoOldText; if (traverser.numNewChars() > 0) { newText = traverser.consumeAvailableNewChars(); } if (traverser.hasMore()) { endChar = traverser.takenCharsStart(); indexIntoOldText = endChar + traverser.numTakenChars(); traverser.consumeAvailableTakenChars(); } else { endChar = oldTextLength; indexIntoOldText = endChar; } if (endChar != startChar || newText.length > 0) { splices.push([startChar, endChar, newText]); } } return splices; } array.toSplices = function() { return splicesFromChanges(this); } array.characterRangeFollowThis = function(selStartChar, selEndChar, insertionsAfter) { var changeset = this; // represent the selection as a changeset that replaces the selection with some finite string. // Because insertions indicate intention, it doesn't matter what this string is, and even // if the selectionChangeset is made to "follow" other changes it will still be the only // insertion. var selectionChangeset = Changeset(changeset.oldLen()).builder().appendOldText(0, selStartChar).appendNewText( "X").appendOldText(selEndChar, changeset.oldLen() - selEndChar).toChangeset(); var newSelectionChangeset = selectionChangeset.follow(changeset, insertionsAfter); var selectionSplices = newSelectionChangeset.toSplices(); function includeChar(i) { if (! includeChar.calledYet) { selStartChar = i; selEndChar = i; includeChar.calledYet = true; } else { if (i < selStartChar) selStartChar = i; if (i > selEndChar) selEndChar = i; } } for(var i=0; i TRUNC) a.push("..."); return a.join(' '); } function unescapeCrazyUnicode(str) { return str.replace(/\\(u....|\\)/g, function(seq) { if (seq == "\\\\") return "\\"; return String.fromCharCode(Number("0x"+seq.substring(2))); }); } var numData, stringData; var binary = false; var typ = str.charAt(0); if (typ == "B" || typ == "A") { var result = Changeset.numberArrayFromString(str, 1); numData = result[0]; stringData = result[1]; if (typ == "A") { stringData = unescapeCrazyUnicode(stringData); } binary = true; } else if (typ == "C") { var barPosition = str.indexOf('|'); numData = str.substring(0, barPosition).split(','); stringData = str.substring(barPosition+1); } else { error("Not a changeset: "+toHex(str)); } var stringDataOffset = 0; var array = []; var ptr; if (binary) { array.push("Changeset", numData[0], numData[1]); var ptr = 2; } else { array.push(numData[0], Number(numData[1]), Number(numData[2])); var ptr = 3; } while (ptr < numData.length) { array.push(Number(numData[ptr++]), Number(numData[ptr++])); var newTextLength = Number(numData[ptr++]); array.push(stringData.substr(stringDataOffset, newTextLength)); stringDataOffset += newTextLength; } if (stringDataOffset != stringData.length) { error("Extra character data beyond end of encoded string ("+toHex(str)+")"); } return Changeset(array); }; Changeset.numberArrayToString = function(nums) { var array = []; function writeNum(n) { // does not support negative numbers var twentyEightBit = (n & 0xfffffff); if (twentyEightBit <= 0x7fff) { array.push(String.fromCharCode(twentyEightBit)); } else { array.push(String.fromCharCode(0xa000 | (twentyEightBit >> 15), twentyEightBit & 0x7fff)); } } writeNum(nums.length); var len = nums.length; for(var i=0;i 0x7fff) { if (n >= 0xa000) { n = (((n & 0x1fff) << 15) | str.charCodeAt(strIndex++)); } else { // legacy format n = (((n & 0x1fff) << 16) | str.charCodeAt(strIndex++)); } } return n; } var len = readNum(); for(var i=0;i> 1); s += s; if (times & 1) s += str; return s; } function chr(n) { return String.fromCharCode(n+48); } function ord(c) { return c.charCodeAt(0)-48; } function runMatcher(c) { // Takes "A" and returns /\u0041+/g . // Avoid creating new objects unnecessarily by caching matchers // as properties of this function. var re = runMatcher[c]; if (re) return re; re = runMatcher[c] = new RegExp("\\u"+("0000"+c.charCodeAt(0).toString(16)).slice(-4)+"+", 'g'); return re; } function runLength(str, idx, c) { var re = runMatcher(c); re.lastIndex = idx; var result = re.exec(str); if (result && result[0]) { return result[0].length; } return 0; } // emptyObj may be a StorableObject Changeset.initAttributedText = function(emptyObj, initialString, initialAuthor) { var obj = emptyObj; obj.authorMap = { 1: (initialAuthor || '') }; obj.text = (initialString || ''); obj.attribs = repeatString(chr(1), obj.text.length); return obj; }; Changeset.gcAttributedText = function(atObj) { // "garbage collect" the list of authors var removedAuthors = []; for(var a in atObj.authorMap) { if (atObj.attribs.indexOf(chr(Number(a))) < 0) { removedAuthors.push(atObj.authorMap[a]); delete atObj.authorMap[a]; } } return removedAuthors; }; Changeset.cloneAttributedText = function(emptyObj, atObj) { var obj = emptyObj; obj.text = atObj.text; // string if (atObj.attribs) obj.attribs = atObj.attribs; // string if (atObj.attribs_c) obj.attribs_c = atObj.attribs_c; // string obj.authorMap = {}; for(var a in atObj.authorMap) { obj.authorMap[a] = atObj.authorMap[a]; } return obj; }; Changeset.applyToAttributedText = function(atObj, C) { C = (C || this); var oldText = atObj.text; var oldAttribs = atObj.attribs; Changeset._assert(C.isChangeset, "applyToAttributedText: 'this' is not a changeset"); Changeset._assert(oldText.length == C.oldLen(), "applyToAttributedText: mismatch "+oldText.length+" / "+C.oldLen()); var textBuf = []; var attribsBuf = []; var authorMap = atObj.authorMap; function authorId(author) { for(var a in authorMap) { if (authorMap[Number(a)] === author) { return Number(a); } } for(var i=1;i<=60000;i++) { // don't use "in" because it's currently broken on StorableObjects if (authorMap[i] === undefined) { authorMap[i] = author; return i; } } } var myBuilder = { appendNewText: function(txt, author) { // object that acts as a "builder" in that it receives requests from // authorSlicer to append text attributed to different authors attribsBuf.push(repeatString(chr(authorId(author)), txt.length)); } }; var authorSlicer; if (C.authors) { authorSlicer = C.authorSlicer(myBuilder); } C.eachStrip(function (s, t, n) { textBuf.push(oldText.substr(s, t), n); attribsBuf.push(oldAttribs.substr(s, t)); if (authorSlicer) { authorSlicer.takeChars(n.length, n); } else { myBuilder.appendNewText(n, ''); } }); atObj.text = textBuf.join(''); atObj.attribs = attribsBuf.join(''); return atObj; }; Changeset.getAttributedTextCharAuthor = function(atObj, idx) { return atObj.authorMap[ord(atObj.attribs.charAt(idx))]; }; Changeset.getAttributedTextCharRunLength = function(atObj, idx) { var c = atObj.attribs.charAt(idx); return runLength(atObj.attribs, idx, c); }; Changeset.eachAuthorInAttributedText = function(atObj, func) { // call func(author, authorNum) for(var a in atObj.authorMap) { if (func(atObj.authorMap[a], Number(a))) break; } }; Changeset.getAttributedTextAuthorByNum = function(atObj, n) { return atObj.authorMap[n]; }; // Compressed attributed text can be cloned, but nothing else until uncompressed!! Changeset.compressAttributedText = function(atObj) { // idempotent, mutates the object, returns it if (atObj.attribs) { atObj.attribs_c = atObj.attribs.replace(/([\s\S])\1{0,63}/g, function(run) { return run.charAt(0)+chr(run.length);; }); delete atObj.attribs; } return atObj; }; Changeset.decompressAttributedText = function(atObj) { // idempotent, mutates the object, returns it if (atObj.attribs_c) { atObj.attribs = atObj.attribs_c.replace(/[\s\S][\s\S]/g, function(run) { return repeatString(run.charAt(0), ord(run.charAt(1))); }); delete atObj.attribs_c; } return atObj; }; })();