UNPKG

ot-socialcalc

Version:

Operational Transformation for socialcalc commands (shareJS compatible)

653 lines (592 loc) 19.3 kB
var SocialCalc = require('socialcalc') , SpreadsheetColumn = require('spreadsheet-column') , column = new SpreadsheetColumn const operationsList = [Set, InsertRow, DeleteRow, InsertCol, DeleteCol, Name, Merge] const operationsHash = operationsList.reduce(function(obj, op) {obj[(new op).type] = op; return obj},{}) const scInstance = new SocialCalc.SpreadsheetControl() exports.create = function() { SocialCalc.ResetSheet(scInstance.sheet) var newScSnapshot = SocialCalc.CreateSheetSave(scInstance.sheet) return newScSnapshot } exports.apply = function(scSnapshot, ops) { // load snapshot into our global SocialCalc instance scInstance.sheet.ParseSheetSave(scSnapshot) // Turn ops into a command string var cmds = unpackOps(ops) .map((op) => op.serialize()) .filter((op) => !!op) .forEach((cmd) => { var error = SocialCalc.ExecuteSheetCommand(scInstance.sheet, new SocialCalc.Parse(cmd), /*saveundo:*/false) if(error) throw new Error(error) }) var newScSnapshot = SocialCalc.CreateSheetSave(scInstance.sheet) return newScSnapshot } exports.transform = function(ops1, ops2, side) { return unpackOps(ops1).map(function(op1) { unpackOps(ops2).forEach(function(op2) { op1.transformAgainst(op2, ('left'==side)) }) return op1 }) } exports.transformCursor = function(cursor, ops/*, isOwnOp*/) { // isOwnOp is not supported for now, its purpose eludes me var cursorOp if (!~cursor.indexOf(':')) { cursorOp = new Set(cursor, null, null) unpackOps(ops).forEach((op) => cursorOp = cursorOp.transformAgainst(op)) return cursorOp.hasEffect? cursorOp.target : null }else{ // If the cursor contains a : it's a range, e.g. A1:C3 return transformRange(cursor, unpackOps(ops)) } } exports.deserializeEdit = function(cmds) { return cmds.split('\n') .reduce((ops, cmd) => { var op operationsList.some((Operation) => op = Operation.parse(cmd)) if(op) op.forEach(op => ops.push(op)) // If nothing recognizes this, we filter it out. return ops }, []) } exports.serializeEdit = function(ops) { return unpackOps(ops).map((op) => op.serialize()).join('\n') } function unpackOps (ops) { return ops .map((op) => operationsHash[op.type].hydrate(op)) } /** ---------------------------- OPERATIONS ---------------------------- */ // These are SocialCalcs commands in the original format (we parse and serialize operations from7to this format): // // set sheet attributename value (plus lastcol and lastrow) // set 22 attributename value // set B attributename value // set A1 attributename value1 value2... (see each attribute in code for details) // set A1:B5 attributename value1 value2... // erase/copy/cut/paste/fillright/filldown A1:B5 all/formulas/format // loadclipboard save-encoded-clipboard-data // clearclipboard // merge C3:F3 // unmerge C3 // insertcol/insertrow C5 // deletecol/deleterow C5:E7 // movepaste/moveinsert A1:B5 A8 all/formulas/format (if insert, destination must be in same rows or columns or else paste done) // sort cr1:cr2 col1 up/down col2 up/down col3 up/down // name define NAME definition // name desc NAME description // name delete NAME // recalc // redisplay // changedrendervalues // startcmdextension extension rest-of-command // sendemail ??? eddy ??? /** All Operations implement the same Interface: Operation#transformAgainst(op, side) : Operation // transforms this op against the passed on in-place. `side` is for tie-breaking. Operation#serialize() : string // Returns the corresponding SocialCalc command (without newline) Operation.parse(cmd:String) : Array<Operation>|false // Checks if the SocialCalc command is equivalent to the Operation type, if so: returns the corresponding operation(s), else it returns false. Operation.hydrade(obj) : Operation // turns a plain object into an Operation instance */ /** * Set operation */ function Set(target, attribute, value) { this.type = 'Set' this.target = target this.attribute = attribute this.value = value this.hasEffect = true // Can become effectless upon transformation } Set.hydrate = function(obj) { return new Set(obj.target, obj.attribute, obj.value) } Set.prototype.transformAgainst = function(op2, side) { if(op2 instanceof Set) { if(op2.target !== this.target) return this if(side) { var obj = new Set(this.target, this.attribute, this.value) obj.hasEffect = false return obj }else return this }else if(op2 instanceof InsertRow && this.target !== 'sheet') { var otherRow = parseCell(op2.newRow)[1] // If this target is a cell if (this.target.match(/[a-z]+[0-9]+/i)) { var myCell = parseCell(this.target) , thisRow = myCell[1] if (otherRow <= thisRow) return new Set(column.fromInt(myCell[0])+(thisRow+1), this.attribute, this.value) else return this }else // this target is a row if (parseInt(this.target) !== NaN) { var thisRow = parseInt(this.target) if (otherRow <= thisRow) return new Set(thisRow+1, this.attribute, this.value) else return this } // if this target is a column else return this }else if (op2 instanceof DeleteRow && this.target !== 'sheet'){ var otherRow = parseCell(op2.row)[1] // If this target is a cell if (this.target.match(/[a-z]+[0-9]+/i)) { var myCell = parseCell(this.target) , thisRow = myCell[1] if (otherCol < thisCol) return new Set(column.fromInt(myCell[0])+(thisRow-1), this.attribute, this.value) else if (otherRow === thisRow) { var obj = new Set(this.target, this.attribute, this.value) obj.hasEffect = false return obj } else return this }else // this target is a row if (parseInt(this.target) === NaN) { var thisRow = this.target if (otherRow <= thisRow) return new Set(String(thisRow-1), this.attribute, this.value) else if (otherCol === thisCol) { var obj = new Set(this.target, this.attribute, this.value) obj.hasEffect = false return obj } else return this } // if this target is a row else return this } if (op2 instanceof InsertCol && this.target !== 'sheet') { var otherCol = parseCell(op2.newCol)[0] // If this target is a cell if (this.target.match(/[a-z]+[0-9]+/i)) { var myCell = parseCell(this.target) , thisCol = myCell[0] if (otherCol <= thisCol) return new Set(column.fromInt(thisCol+1)+myCell[1], this.attribute, this.value) else return this }else // this target is a col if (parseInt(this.target) === NaN) { var thisCol = column.fromStr(this.target) if (otherCol <= thisCol) return new Set(column.fromInt(thisCol+1), this.attribute, this.value) else return this } // if this target is a row else return this }else if (op2 instanceof DeleteCol && this.target !== 'sheet'){ var otherCol = parseCell(op2.col)[0] // If this target is a cell if (this.target.match(/[a-z]+[0-9]+/i)) { var myCell = parseCell(this.target) , thisCol = myCell[0] if (otherCol < thisCol) return new Set(column.fromInt(thisCol-1)+myCell[1], this.attribute, this.value) else if (otherCol === thisCol) { var obj = new Set(this.target, this.attribute, this.value) obj.hasEffect = false return obj } else return this }else // this target is a col if (parseInt(this.target) === NaN) { var thisCol = column.fromStr(this.target) if (otherCol <= thisCol) return new Set(column.fromInt(thisCol-1), this.attribute, this.value) else if (otherCol === thisCol) { var obj = new Set(this.target, this.attribute, this.value) obj.hasEffect = false return obj } else return this } // if this target is a row else return this } return this } Set.prototype.serialize = function() { if(!this.hasEffect) return '' return 'set '+this.target+' '+this.attribute+' '+this.value } Set.parse = function(cmdstr) { if(0 !== cmdstr.indexOf('set')) return var parts = cmdstr.split(' ') , cmd = parts[0] , target = parts[1] , attr = parts[2] , value = cmdstr.substr(cmd.length+1+target.length+1+attr.length+1) // if this a range? if(~target.indexOf(':')) { return resolveRange(target).map((target) => new Set(target, attr, value)) }else { return [new Set(target, attr, value)] } } /** * InsertRow operation */ function InsertRow(newRow) { this.type = 'InsertRow' this.newRow = newRow } InsertRow.hydrate = function(obj) { return new InsertRow(obj.newRow) } InsertRow.prototype.transformAgainst = function(op, left) { if(op instanceof InsertRow) { var otherCell = parseCell(op.newRow) , myCell = parseCell(this.newRow) if (otherCell[1] < myCell[1]) { return new InsertRow(column.fromInt(myCell[0])+(myCell[1]+1)) }else if (otherCell[1] === myCell[1]) { if(left) return new InsertRow(column.fromInt(myCell[0])+(myCell[1]+1)) else return this }else{ return this } } else if (op instanceof DeleteRow) { var otherRow = parseCell(op.row)[1] , mycell = parseCell(this.newRow) if (otherRow < myCell[1]) { return new DeleteCol(column.fromInt(myCell[0])+(myCell[1]-1)) }else { return this } } return this } InsertRow.parse = function(cmd) { if(0 !== cmd.indexOf('insertrow ')) return false return [new InsertRow(cmd.substr('insertrow '.length))] } InsertRow.prototype.serialize = function() { return 'insertrow '+this.newRow } /** * DeleteRow operation */ function DeleteRow(row) { this.type = 'DeleteRow' this.row = row } DeleteRow.hydrate = function(obj) { return new DeleteRow(obj.row) } DeleteRow.prototype.transformAgainst = function(op, left) { if(op instanceof InsertRow) { var otherCell = parseCell(op.newRow) , myCell = parseCell(this.row) if (otherCell[1] === myCell[1]) { if(left) return new InsertCol(column.fromInt(myCell[0])+(myCell[1]+1)) else return this } else if (otherCell[1] < myCell[1]) { return new InsertCol(column.fromInt(myCell[0])+(myCell[1]+1)) }else{ return this } }else if (op instanceof DeleteRow) { var otherRow = parseCell(op.row)[1] , mycell = parseCell(this.row) if (otherRow === myCell[1]) { // tie! break it! // If both happen to delete the same row, one wins, the other becomes a noop if (left) return new DeleteRow(null) else return this } else if (otherRow < myCell[1]) { return new DeleteCol(column.fromInt(myCell[0])+(myCell[1]-1)) }else { return this } } return this } DeleteRow.parse = function(cmd) { if(0 !== cmd.indexOf('deleterow ')) return false var val = cmd.substr('deletecol '.length) if (~val.indexOf(':')) return resolveRowRange(val).map(cell => new DeleteRow(cell)) return [new DeleteCol(val)] } DeleteRow.prototype.serialize = function() { if (!this.row) return '' return 'deleterow '+this.row } /** * InsertCol operation */ function InsertCol(newCol) { this.type = 'InsertCol' this.newCol = newCol } InsertCol.hydrate = function(obj) { return new InsertCol(obj.newCol) } InsertCol.prototype.transformAgainst = function(op, left) { if(op instanceof InsertCol) { var otherCell = parseCell(op.newRow) , myCell = parseCell(this.newRow) if (otherCell[0] === myCell[0]) { if(left) return new InsertCol(column.fromInt(myCell[0]+1)+myCell[1]) else return this } else if (otherCell[0] < myCell[0]) { return new InsertCol(column.fromInt(myCell[0]+1)+myCell[1]) }else{ return this } } else if (op instanceof DeleteCol) { var otherCol = parseCell(op.col)[0] , mycell = parseCell(this.newCol) if (otherCol < myCell[0]) { return new InsertCol(column.fromInt(myCell[0]-1)+myCell[1]) }else { return this } } return this } InsertCol.parse = function(cmd) { if(0 !== cmd.indexOf('insertcol ')) return false return [new InsertCol(cmd.substr('insertcol '.length))] } InsertCol.prototype.serialize = function() { return 'insertcol '+this.newCol } /** * DeleteCol operation */ function DeleteCol(col) { this.type = 'DeleteCol' this.col = col } DeleteCol.hydrate = function(obj) { return new DeleteCol(obj.col) } DeleteCol.prototype.transformAgainst = function(op, left) { if(op instanceof InsertCol) { var otherCell = parseCell(op.newRow) , myCell = parseCell(this.newRow) if (otherCell[0] === myCell[0]) { if(left) return new InsertCol(column.fromInt(myCell[0]+1)+myCell[1]) else return this } else if (otherCell[0] < myCell[0]) { return new InsertCol(column.fromInt(myCell[0]+1)+myCell[1]) }else{ return this } }else if (op instanceof DeleteCol) { var otherCol = parseCell(op.col)[0] , mycell = parseCell(this.col) if (otherCol === myCell[0]) { // tie! break it! if (left) return new DeleteCol(null) else return this } else if (otherCol < myCell[0]) { // This now only catches 'less' (we caught equal already^^) return new DeleteCol(column.fromInt(myCell[0]-1)+myCell[1]) }else { return this } } return this } DeleteCol.parse = function(cmd) { if(0 !== cmd.indexOf('deletecol ')) return false var val = cmd.substr('deletecol '.length) if (~val.indexOf(':')) return resolveColRange(val).map(cell => new DeleteCol(cell)) return [new DeleteCol(val)] } DeleteCol.prototype.serialize = function() { if (!this.col) return '' return 'deletecol '+this.col } /** * Merge operation */ function Merge(target) { this.type = 'Merge' this.target = target } Merge.hydrate = function(op) { return new Merge(op.target) } Merge.prototype.serialize = function() { if (!this.target) return '' return 'merge '+this.target } Merge.parse = function(cmdstr) { if(cmdstr.indexOf('merge ') !== 0) return false var target = cmdstr.substr('merge '.length) return new Merge(target) } Merge.prototype.transformAgainst = function(op) { // We can reuse the selection transformation here var newRange = transformRange(this.target, [op]) if (!~newRange.indexOf(':')) return new Merge(null) // if it'S not a range anymore, this will become a noop. return new Merge(newRange) } /** * Name operation */ function Name(name, action, val) { this.type = 'Name' this.name = name this.action = action this.value = val } Name.hydrate = function(op) { return new Name(op.name, op.action, op.value) } Name.prototype.serialize = function() { if (!this.name) return '' return 'name '+this.action+' '+this.name+' '+this.value } Name.parse = function(cmdstr) { if(cmdstr.indexOf('name ') !== 0) return false var cmd = cmdstr.substr('name '.length).split(' ') return new Name(cmd[1], cmd[0], cmd[2]) } Name.prototype.transformAgainst = function(op, left) { if (op instanceof Name && op.name === this.name) { // def, def => tie // def, desc => - // def, del => tie // desc, def => - // desc, desc => tie // desc, del => tie // del, def => tie // del, desc => tie // del, del => noop if ('define' === this.action) { if (op.action === 'define' || op.action === 'delete') { // def, def => tie; def, del => tie if (left) return this else return new Name(null, this.action, this.value) } else return this } else if ('desc' === this.action) { if (op.action === 'desc' || op.action === 'delete') { // desc, desc => tie; desc, del => tie if (left) return this else return new Name(null, this.action, this.value) } else return this } else if ('delete' === this.action) { if (op.action === 'delete') { // del, del => noop return new Name(null, this.action, this.value) } else { // del, def => tie; del, desc => tie if (left) return this return new Name(null, this.action, this.value) } } } return this } /** * Utility functions */ function parseCell(cell) { var match = cell.match(/([a-z]+)([0-9]+)/i) if(!match) throw new Error('invalid cell id '+cell) return [column.fromStr(match[1]), parseInt(match[2])] } function resolveRange(range) { if(!range.indexOf(':')) throw new Error('not a range.') var parts = range.split(':') , start = parseCell(parts[0]) , end = parseCell(parts[1]) var cells = [] for (var i=start[0]; i<=end[0]; i++) { for (var j=start[1]; j <= end[1]; j++) { cells.push(column.fromInt(i)+j) } } return cells } function resolveColRange(range) { if(!range.indexOf(':')) throw new Error('not a range.') var parts = range.split(':') , start = parseCell(parts[0]) , end = parseCell(parts[1]) var cells = [] for (var i=start[0]; i<=end[0]; i++) { cells.push(column.fromInt(i)+start[1]) } cells.reverse() return cells } function resolveRowRange(range) { if(!range.indexOf(':')) throw new Error('not a range.') var parts = range.split(':') , start = parseCell(parts[0]) , end = parseCell(parts[1]) , col = column.fromInt(start[0]) var cells = [] for (var i=start[1]; i<=end[1]; i++) { cells.push(col+i) } cells.reverse() return cells } /** * Transforms a range against an array of ops */ function transformRange(range, ops) { var rangeComps = range.split(':') , newRange var start = rangeComps[0] ops.forEach(op => start = transformRangeAnchor(start, op, /*isStart:*/true)) var end = rangeComps[1] ops.forEach(op => end = transformRangeAnchor(end, op, /*isStart:*/false)) if (start === end) return start return start+':'+end } /** * Transforms a range anchor, taking into account whether it's the start or end */ function transformRangeAnchor(target, op, isStart) { var thisCell = parseCell(target) if (op instanceof InsertCol) { var otherCell = parseCell(op.newCol) if (otherCell[0] <= thisCell[0]) return column.fromInt(thisCell[0]+1)+thisCell[1] } else if (op instanceof DeleteCol) { var otherCell = parseCell(op.col) if (otherCell[0] < thisCell[0]) return column.fromInt(thisCell[0]-1)+thisCell[1] if (otherCell[0] === thisCell[0]) { // Spreadsheet selection is different from text selection: // While text selection ends in the first *not* selected char ( "foo| |bar" => 3,4) // ... spreadsheet selection ends in the last selected cell. Thus we need to // differentiate between start and end. Shame on those who didn't think about this! if (!isStart) return column.fromInt(thisCell[0]-1)+thisCell[1] } } else if (op instanceof InsertRow) { var otherCell = parseCell(op.newRow) if (otherCell[1] <= thisCell[1]) return column.fromInt(thisCell[0])+(thisCell[1]+1) } else if (op instanceof DeleteRow) { var otherCell = parseCell(op.col) if (otherCell[1] < thisCell[1]) return column.fromInt(thisCell[0])+(thisCell[1]-1) if (otherCell[1] === thisCell[1]) { if (!isStart) return column.fromInt(thisCell[0])+(thisCell[1]-1) } } // If nothing has returned already then this anchor doesn't change return target } exports.compose = function(ops1, ops2) { return ops1.concat(ops2) }