UNPKG

json-joy

Version:

Collection of libraries for building collaborative editing apps.

214 lines (213 loc) 7.38 kB
import { deepEqual } from '@jsonjoy.com/util/lib/json-equal/deepEqual'; import { ObjNode, ArrNode, ConNode } from '../nodes'; import { toPath, isChild } from '@jsonjoy.com/json-pointer/lib/util'; import { interval } from '../../json-crdt-patch/clock'; export class JsonPatch { model; pfx; constructor(model, pfx = []) { this.model = model; this.pfx = pfx; } apply(ops) { const length = ops.length; this.model.api.transaction(() => { for (let i = 0; i < length; i++) this.applyOp(ops[i]); }); return this; } applyOp(op) { switch (op.op) { case 'add': this.add(op.path, op.value); break; case 'remove': this.remove(op.path); break; case 'replace': this.replace(op.path, op.value); break; case 'move': this.move(op.path, op.from); break; case 'copy': this.copy(op.path, op.from); break; case 'test': this.test(op.path, op.value); break; case 'str_ins': this.strIns(op.path, op.pos, op.str); break; case 'str_del': this.strDel(op.path, op.pos, op.len ?? 0, op.str); break; default: throw new Error('UNKNOWN_OP'); } this.model.api.apply(); return this; } builder() { return this.model.api.builder; } toPath(path) { return this.pfx.concat(toPath(path)); } add(path, value) { const builder = this.builder(); const steps = this.toPath(path); if (!steps.length) this.setRoot(value); else { const objSteps = steps.slice(0, steps.length - 1); const node = this.model.api.find(objSteps); const key = steps[steps.length - 1]; if (node instanceof ObjNode) { builder.insObj(node.id, [[String(key), builder.json(value)]]); // TODO: see if "con" nodes can be used here in some cases. } else if (node instanceof ArrNode) { const builderValue = builder.json(value); if (key === '-') { const length = node.length(); const after = node.find(length - 1) || node.id; builder.insArr(node.id, after, [builderValue]); } else { const index = ~~key; if ('' + index !== key) throw new Error('INVALID_INDEX'); if (!index) builder.insArr(node.id, node.id, [builderValue]); else { const after = node.find(index - 1); if (!after) throw new Error('NOT_FOUND'); builder.insArr(node.id, after, [builderValue]); } } } else throw new Error('NOT_FOUND'); } } remove(path) { const builder = this.builder(); const steps = this.toPath(path); if (!steps.length) this.setRoot(null); else { const objSteps = steps.slice(0, steps.length - 1); const node = this.model.api.find(objSteps); const key = steps[steps.length - 1]; if (node instanceof ObjNode) { const stringKey = String(key); const valueNode = node.get(stringKey); if (valueNode === undefined) throw new Error('NOT_FOUND'); if (valueNode instanceof ConNode && valueNode.val === undefined) throw new Error('NOT_FOUND'); builder.insObj(node.id, [[stringKey, builder.const(undefined)]]); } else if (node instanceof ArrNode) { const key = steps[steps.length - 1]; const index = ~~key; if (typeof key === 'string' && '' + index !== key) throw new Error('INVALID_INDEX'); const id = node.find(index); if (!id) throw new Error('NOT_FOUND'); builder.del(node.id, [interval(id, 0, 1)]); } else throw new Error('NOT_FOUND'); } } replace(path, value) { this.remove(path); this.add(path, value); } move(path, from) { path = toPath(path); from = toPath(from); if (isChild(from, path)) throw new Error('INVALID_CHILD'); const json = this.json(this.toPath(from)); this.remove(from); this.add(path, json); } copy(path, from) { path = toPath(path); const json = this.json(this.toPath(from)); this.add(path, json); } test(path, value) { path = this.toPath(path); const json = this.json(path); if (!deepEqual(json, value)) throw new Error('TEST'); } strIns(path, pos, str) { path = this.toPath(path); const { node } = this.model.api.str(path); const length = node.length(); const after = pos ? node.find(length < pos ? length - 1 : pos - 1) : node.id; if (!after) throw new Error('OUT_OF_BOUNDS'); this.builder().insStr(node.id, after, str); } strDel(path, pos, len, str = '') { path = this.toPath(path); const { node } = this.model.api.str(path); const length = node.length(); if (length <= pos) return; const deletionLength = Math.min(len ?? str.length, length - pos); const range = node.findInterval(pos, deletionLength); if (!range) throw new Error('OUT_OF_BOUNDS'); this.builder().del(node.id, range); } get(path) { return this._get(this.toPath(path)); } _get(steps) { const model = this.model; if (!steps.length) return model.view(); else { try { const objSteps = steps.slice(0, steps.length - 1); const node = model.api.find(objSteps); const key = steps[steps.length - 1]; if (node instanceof ObjNode) { return node.get(String(key))?.view(); } else if (node instanceof ArrNode) { const index = ~~key; if ('' + index !== key) throw new Error('INVALID_INDEX'); const arrNode = node.getNode(index); if (!arrNode) throw new Error('NOT_FOUND'); return arrNode.view(); } } catch { return; } } return undefined; } json(steps) { const json = this._get(steps); if (json === undefined) throw new Error('NOT_FOUND'); return json; } setRoot(json) { const builder = this.builder(); builder.root(builder.json(json)); } }