www.spaceplanner.app

Web client to the spaceplanner API
git clone git://jacobedwards.org/www.spaceplanner.app
Log | Files | Refs

backend.js (26626B)


      1 import * as api from "/lib/api.js"
      2 import { AsyncLock } from "/lib/async-lock.js"
      3 
      4 // Sequence numbers for uniqueKey
      5 let sequences = {}
      6 
      7 const objectPaths = {
      8 	pnt: "points",
      9 	pntmap:  "pointmaps",
     10 	fur: "furniture",
     11 	furmap: "furniture_maps"
     12 }
     13 
     14 const objectTypes = {
     15 	points: "pnt",
     16 	pointmaps: "pntmap",
     17 	furniture: "fur",
     18 	furniture_maps: "furmap"
     19 }
     20 
     21 class BackendHistory {
     22 	constructor() {
     23 			// The current position in history (diffs)
     24 			// -1 for before everything
     25 			this.place = -1,
     26 
     27 			// Points in this.diffs which represent
     28 			// a completed action, etc. (Previously
     29 			// called groups.)
     30 			//   The diff marked is the final in the
     31 			// "group"
     32 			this.marks = [],
     33 
     34 			// Actual changes
     35 			this.diffs = [],
     36 
     37 			// Says the time at which the diffs were truncated
     38 			// It's purpose is to tell the backend it can't just
     39 			// update the server with the diffs.
     40 			this.truncated = null
     41 	}
     42 
     43 	set place(v) {
     44 		console.debug("Backend.History.place", "set", v)
     45 		this._place = v
     46 	}
     47 
     48 	get place() {
     49 		return this._place
     50 	}
     51 
     52 	set diff(diff) {
     53 		this.place = diff.id
     54 		return diff
     55 	}
     56 
     57 	get diff() {
     58 		return this.diffs[this.place]
     59 	}
     60 
     61 	get last() {
     62 		return this.diffs.length - 1
     63 	}
     64 
     65 	mark() {
     66 		if (this.marks.at(-1) === this.place) {
     67 			console.warn("Backend.History.mark", this.place, "Diff already marked")
     68 			return null
     69 		}
     70 		let mark = this.marks.push(this.place) - 1
     71 		console.debug("Backend.History.mark", mark, this.place)
     72 		return mark
     73 	}
     74 
     75 	addDiff(op, path, value, options) {
     76 		if (!op || !path) {
     77 			throw new Error("Requires op and path")
     78 		}
     79 		if (op === "add") {
     80 			if (!value) {
     81 				throw new Error("add: Requires value")
     82 			}
     83 		} else if (op !== "remove") {
     84 			throw new Error("Only add and remove operations supported")
     85 		}
     86 
     87 		if (this.place != this.last) {
     88 			this.truncate()
     89 		}
     90 
     91 		/*
     92 		 * TODO: Keep a table of the last diffs for various paths
     93 		 * to speed this up
     94 		 */
     95 		let oldDiff
     96 		for (let i = this.place; !oldDiff && i >= 0; --i) {
     97 			if (this.diffs[i].path === path) {
     98 				if (this.diffs[i].op === "remove") {
     99 					throw new Error("Cannot reuse old ID")
    100 				}
    101 				oldDiff = i
    102 			}
    103 		}
    104 
    105 		let m = this.diffMark()
    106 		if (op === "add" && oldDiff != undefined && this.marks[m] != this.place &&
    107 		    this.diffMark(oldDiff) === m) {
    108 			let d = this.diffs[oldDiff]
    109 			d.value = value
    110 			d.time = Date.now()
    111 			this.truncated = Date.now()
    112 			console.debug("Backend.History.addDiff", "replacing", d.id)
    113 			return d
    114 		}
    115 
    116 		let oldValue = (oldDiff != null) ? this.diffs[oldDiff].value : undefined
    117 		if (op === "add") {
    118 			op = oldValue ? "replace" : "new"
    119 		} else {
    120 			if (oldValue == null) {
    121 				throw new Error("Remove requires oldValue")
    122 			}
    123 		}
    124 
    125 		let diff = {
    126 			type: "diff",
    127 			op: op,
    128 			path: path,
    129 			time: Date.now()
    130 		}
    131 
    132 		if (value) {
    133 			diff.value = structuredClone(value)
    134 		}
    135 
    136 		if (oldValue) {
    137 			diff.oldValue = structuredClone(oldValue)
    138 		}
    139 
    140 		if (!options.clean) {
    141 			diff.dirty = true
    142 		}
    143 
    144 		diff.id = this.diffs.push(diff) - 1
    145 		this.place = diff.id
    146 		console.debug("Backend.History.addDiff", diff.id, diff)
    147 		return diff
    148 	}
    149 
    150 	diffMark(diff) {
    151 		const r = function(mark) {
    152 			console.debug("Backend.History.diffMark", { diff, mark })
    153 			return mark
    154 		}
    155 
    156 		diff = diff ?? this.place
    157 		if (!this.marks[0] || diff < this.marks[0]) {
    158 			return r(-1)
    159 		}
    160 
    161 		// Use efficient algorithm
    162 		for (let i = 0; i < this.marks.length; ++i) {
    163 			if (diff <= this.marks[i]) {
    164 				return r(i)
    165 			}
    166 		}
    167 
    168 		return r(this.marks.length - 1)
    169 	}
    170 
    171 	truncate() {
    172 		if (this.place >= this.last) {
    173 			if (this.place > this.last) {
    174 				throw new Error("There is a bug in history")
    175 			}
    176 			return
    177 		}
    178 
    179 		let mark = this.diffMark(this.place)
    180 		console.log("Backend.History.truncate", { diff: this.place, mark })
    181 		this.diffs = this.between(-1, this.place)
    182 		this.marks = this.marks.slice(0, mark + 1)
    183 		this.mark()
    184 		this.truncated = Date.now()
    185 	}
    186 
    187 	betweenMarks(a, b) {
    188 		return between(this.marks[a], this.marks[b])
    189 	}
    190 
    191 	// Get the required operations to go from diff a to diff b
    192 	between(a, b) {
    193 		const backend = this
    194 		const getDiff = function(v) {
    195 			if (typeof v === "number") {
    196 				if (v < -1) {
    197 					return -1
    198 				}
    199 				return v
    200 			}
    201 			if (typeof(v) === "object") {
    202 				if (!v.type || v.type !== "diff") {
    203 					throw new Error(v + ": Expected 'diff' value for type field")
    204 				}
    205 				return v.id
    206 			}
    207 			throw new Error(v + ": Invalid diff")
    208 		}
    209 		const getParams = function(from, to, max) {
    210 			from = getDiff(from)
    211 			to = getDiff(to)
    212 
    213 			if (from > max || to > max) {
    214 				throw new Error(from + ":" + to + ": Maximum range of " + max)
    215 			}
    216 			from += 1
    217 			to += 1
    218 			if (from === to) {
    219 				return null
    220 			}
    221 			if (from > to) {
    222 				return { reverse: true, from: to, to: from }
    223 			}
    224 			return { from: from, to: to }
    225 		}
    226 
    227 		/*
    228 		 * So 'a' is already applied, and we want the state to look
    229 		 * like what it did when 'b' was added, so if we're going
    230 		 * forward, skip 'a', but if going backward include it.
    231 		 */
    232 		let params = getParams(a, b, this.diffs.length)
    233 		if (!params) {
    234 			return []
    235 		}
    236 		let diffs = this.diffs.slice(params.from, params.to)
    237 		if (params.reverse) {
    238 			diffs = this.reverseDiffs(diffs)
    239 		}
    240 		console.debug("Backend.History.between",
    241 			params.reverse ? "reversed" : "forward", params.from, params.to, diffs)
    242 		return diffs
    243 	}
    244 
    245 	reverseDiffs(diffs) {
    246 		for (let i in diffs) {
    247 			diffs[i] = this.reverseDiff(diffs[i])
    248 		}
    249 		return diffs.reverse()
    250 	}
    251 
    252 	reverseDiff(diff) {
    253 		diff = structuredClone(diff)
    254 
    255 		if (diff.op === "new") {
    256 			diff.op = "remove"
    257 			diff.oldValue = diff.value
    258 			delete diff.value
    259 		} else if (diff.op === "replace") {
    260 			let t = diff.value
    261 			diff.value = diff.oldValue
    262 			diff.oldValue = t
    263 		} else if (diff.op === "remove") {
    264 			if (!diff.oldValue) {
    265 				throw new Error("There should be an old value")
    266 			}
    267 			diff.op = "new"
    268 			diff.value = diff.oldValue
    269 			delete diff.oldValue
    270 		} else {
    271 			throw new Error(diff.op + ": Unsupported operation")
    272 		}
    273 
    274 		return diff
    275 	}
    276 
    277 	dirty() {
    278 		return this.diffs.filter(item => item.dirty)
    279 	}
    280 
    281 	// Step to the end of the next group
    282 	forward(diff) {
    283 		let cur = this.diffMark(diff)
    284 		let to = cur + 1
    285 		console.debug("Backend.History.forward", diff, `from ${cur} to ${to}`)
    286 		if (this.marks[to] == undefined || this.marks[to] == this.last) {
    287 			console.warn("Cannot go forward; at the end")
    288 			return this.last
    289 		}
    290 		if (to == this.marks.length - 1) {
    291 			return this.last
    292 		}
    293 		return this.marks[to]
    294 	}
    295 
    296 	// Step to the beginning of the previous group
    297 	backward(diff) {
    298 		let cur = this.diffMark(diff)
    299 		let to = cur - 1
    300 		console.debug("Backend.History.backward", diff, `from ${cur} to ${to}`)
    301 		if (to < 0) {
    302 			if (cur < 0) {
    303 				console.warn("Cannot go backward; already at beginning")
    304 			}
    305 			return -1
    306 		}
    307 		return this.marks[to]
    308 	}
    309 }
    310 
    311 export class FloorplanBackend {
    312 	constructor(floorplan, options) {
    313 		let backend = this
    314 		if (!options) {
    315 			options = {}
    316 		}
    317 
    318 		if (floorplan && (!floorplan.user || !floorplan.id)) {
    319 			throw new Error("Invalid floorplan given")
    320 		}
    321 		this.floorplan = floorplan
    322 
    323 		this.lock = new AsyncLock({ timeout: 15 * 1000 });
    324 
    325 		if (options.callbacks) {
    326 			this.callbacks = options.callbacks
    327 		}
    328 
    329 		this.params = {}
    330 		this.initialized = api.fetch("GET", "pointmaps")
    331 			.then(function(resp) {
    332 				backend.params.pointmaps = resp
    333 			})
    334 		this.initialized = Promise.all([this.initialized,
    335 			api.fetch("GET", "furniture")
    336 				.then(function(furniture) {
    337 					backend.params.furniture = furniture
    338 				})
    339 			])
    340 
    341 		// Cache for server (both from and to)
    342 		this.cache = {
    343 			// { pointId: { x: Number, y: Number } }
    344 			points: {},
    345 
    346 			/*
    347 			 * { pointMapId: { type: mapType, from: pointId, to: pointId } }
    348 			 */
    349 			pointmaps: {},
    350 
    351 			/*
    352 			 * Furniture definitions:
    353 			 * { id: { type: furnitureType, name: name, width: width, depth: depth } }
    354 			 */
    355 			furniture: {},
    356 
    357 			/*
    358 			 * Furniture map definitions:
    359 			 * { id: { furniture_id*: id, layout: layout, x: x, y: y, angle: angle } }
    360 			 *
    361 			 * [*] references if from furniture/defs
    362 			 */
    363 			furniture_maps: {}
    364 		}
    365 
    366 		// Reverse lookup table for pointmaps
    367 		this.mappedPoints = {
    368 			/*
    369 			 * pointA: {
    370 			 * 	pointB: pointmap
    371 			 * }
    372 			 * (and pointB: { pointA: pointmap })
    373 			 */
    374 		}
    375 
    376 		this.history = new BackendHistory()
    377 
    378 		// Server's position in history
    379 		this.serverPosition = -1
    380 
    381 		// Time of last server update
    382 		this.serverUpdated = null
    383 
    384 		// A map of server idPaths pointing to localIDs
    385 		this.localIDs = {}
    386 
    387 		// A map of local ids pointing to server ids
    388 		this.serverIDs = {}
    389 	}
    390 
    391 	get endpoint() {
    392 		if (!this.floorplan) {
    393 			throw new Error("Cannot access API: No floorplan (in demo mode)")
    394 		}
    395 		return `floorplans/${this.floorplan.user}/${this.floorplan.id}/data`
    396 	}
    397 
    398 	// Apply's diffs in order to get to the state at the beginning of the given diff id
    399 	reconstructTo(diff) {
    400 		let diffs = this.history.between(this.history.place, diff)
    401 		this.applyDiff(diffs, { nodiff: true })
    402 		this.history.place = diff
    403 		console.debug("Backend.reconstructTo", "Reconstructed state to", diff)
    404 		return diff
    405 	}
    406 
    407 	undo() {
    408 		this.reconstructTo(this.history.backward())
    409 	}
    410 
    411 	redo() {
    412 		this.reconstructTo(this.history.forward())
    413 	}
    414 
    415 	/*
    416 	 * Add some type of data within the cache.
    417 	 * If key is not given, a random one will be generated.
    418 	 * If clean is not given, it is marked dirty
    419 	 * (thus data from the server, with a known key, can be marked clean)
    420 	 */
    421 	addData(idOrType, value, options) {
    422 		options = options ?? {}
    423 
    424 		let id
    425 		try {
    426 			id = idString(parseID(idOrType))
    427 		}
    428 		catch {
    429 			id = this.newID(objectTypes[idOrType])
    430 		}
    431 
    432 		if (idType(id) === "pntmap") {
    433 			this.updateMappedPoints(value.a, value.b, id)
    434 		}
    435 
    436 		console.debug("Backend.addData", id, value)
    437 		let t = idTable(id)
    438 		this.cache[t][id] = value
    439 		if (!options.nodiff) {
    440 			this.cb("patch",  this.history.addDiff("add", idPath(id), value, options))
    441 		}
    442 
    443 		return id
    444 	}
    445 
    446 	removeData(id, options) {
    447 		options = options ?? {}
    448 
    449 		console.debug("Backend.removeData", id)
    450 		let t = idTable(id)
    451 		if (!this.cache[t][id]) {
    452 			throw new Error("Expected " + id + " to exist")
    453 		}
    454 
    455 		if (idType(id) === "pntmap") {
    456 			this.updateMappedPoints(this.cache[t][id].a, this.cache[t][id].b, null)
    457 		}
    458 
    459 		if (!options.nodiff) {
    460 			this.cb("patch", this.history.addDiff("remove", idPath(id), null, options))
    461 		}
    462 		delete this.cache[t][id]
    463 	}
    464 
    465 	addPoint(params, id) {
    466 		const p = this.updatedObject(params, id, {
    467 			x: {
    468 				required: true,
    469 				parse: parseInt
    470 			},
    471 			y: {
    472 				required: true,
    473 				parse: parseInt
    474 			}
    475 		})
    476 		return this.addData(id ?? "points", p)
    477 	}
    478 
    479 	removePoint(id, options) {
    480 		options = options ?? {}
    481 
    482 		if (!this.mappedPoints[id]) {
    483 			return this.removeData(id, options)
    484 		}
    485 
    486 		if (!options.unmap && !options.recurse) {
    487 			throw new Error("Point is mapped")
    488 		}
    489 
    490 		for (let other in this.mappedPoints[id]) {
    491 			this.unmapPoints(this.mappedPoints[id][other])
    492 		}
    493 
    494 		this.removeData(id, options)
    495 
    496 		if (options.recurse) {
    497 			this.removeOrphans()
    498 		}
    499 	}
    500 
    501 	mapPoints(params, id) {
    502 		const backend = this
    503 		const validPoint = function(id) {
    504 			return idType(id) === "pnt" && backend.obj(id)
    505 		}
    506 		const m = this.updatedObject(params, id, {
    507 			type: {
    508 				required: true,
    509 				default: "wall",
    510 				validate: function(type) {
    511 					let types = backend.params.pointmaps.types
    512 					for (let i = 0; i < types.length; ++i) {
    513 						if (type === types[i]) {
    514 							return true
    515 						}
    516 					}
    517 					return false
    518 				}
    519 			},
    520 			a: {
    521 				required: true,
    522 				validate: validPoint
    523 			},
    524 			b: {
    525 				required: true,
    526 				validate: validPoint
    527 			},
    528 			door_swing: {
    529 				validate: function(swing) {
    530 					switch (swing) {
    531 					case "a+": case "a-": case "b+": case "b-":
    532 						return true
    533 					default:
    534 						return false
    535 					}
    536 				}
    537 			}
    538 		})
    539 		if (m.a === m.b) {
    540 			throw new Error(`${m.a}:${m.b}: Cannot map a point to itself`)
    541 		}
    542 		this.addData(this.whichPointMap(m.a, m.b) ?? "pointmaps", m)
    543 	}
    544 
    545 	unmapPoints(id, options) {
    546 		options = options ?? {}
    547 		this.removeData(id, options)
    548 		if (options.recurse) {
    549 			this.removeOrphans()
    550 		}
    551 	}
    552 
    553 	addFurniture(params, id) {
    554 		let backend = this
    555 		const f = this.updatedObject(params, id, {
    556 			width: {
    557 				required: true,
    558 				parse: parseSize
    559 			},
    560 			depth: {
    561 				required: true,
    562 				parse: parseSize
    563 			},
    564 			type: {
    565 				required: true,
    566 				type: "string",
    567 				validate: function(type) {
    568 					return backend.params.furniture[type] != null
    569 				}
    570 			},
    571 			name: {
    572 				type: "string",
    573 				validate: function(name) {
    574 					let maps = backend.cache.furniture
    575 					for (let k in maps) {
    576 						if (k != id && maps[k].name != null && maps[k].name === name) {
    577 							return false
    578 						}
    579 					}
    580 					return true
    581 				}
    582 			},
    583 			// Could do with verifying this
    584 			style: {
    585 				type: "string"
    586 			}
    587 		})
    588 
    589 		if (f.style != null && this.params.furniture[f.type].styles.indexOf(f.style) < 0) {
    590 			throw new Error(`${f.style} style for ${f.type} type: Invalid style for type`)
    591 		}
    592 
    593 		return this.addData(id ?? "furniture", f)
    594 	}
    595 
    596 	removeFurniture(id, options) {
    597 		for (let map in this.cache.furniture_maps) {
    598 			if (map.furniture === id) {
    599 				this.unmapFurniture(map)
    600 			}
    601 		}
    602 		this.removeData(id, options)
    603 	}
    604 
    605 	mapFurniture(params, id) {
    606 		let backend = this
    607 
    608 		let fm = this.updatedObject(params, id, {
    609 			x: {
    610 				required: true,
    611 				parse: parseInt
    612 			},
    613 			y: {
    614 				required: true,
    615 				parse: parseInt
    616 			},
    617 			angle: {
    618 				required: true,
    619 				default: 0,
    620 				parse: function(input) {
    621 					let angle = parseInt(input)
    622 					if (angle < 0 || angle >= 360) {
    623 						throw new Error(angle + ": Angle must be between 0 and 359 degrees")
    624 					}
    625 					return angle
    626 				}
    627 			},
    628 			layout: {
    629 				required: true,
    630 				default: "1",
    631 				validate: function(input) {
    632 					return typeof input === "string"
    633 				}
    634 			},
    635 			furniture_id: {
    636 				required: true,
    637 				validate: function(id) {
    638 					return idType(id) === "fur" && backend.obj(id)
    639 				}
    640 			}
    641 		})
    642 
    643 		return this.addData(id ?? "furniture_maps", fm)
    644 	}
    645 
    646 	unmapFurniture(id, options) {
    647 		this.removeData(id, options)
    648 	}
    649 
    650 	addMappedFurniture(params, id) {
    651 		params.furniture_id = this.addFurniture(params, id ? this.reqObj(id).furniture_id : null)
    652 		return this.mapFurniture(params, id)
    653 	}
    654 
    655 	updatedObject(params, id, vd) {
    656 		let obj = id ? structuredClone(this.reqObj(id)) : {}
    657 
    658 		params = structuredClone(params)
    659 		for (let k in vd) {
    660 			let vdk = vd[k]
    661 			if (params[k] === undefined) {
    662 				if (obj[k] !== undefined || vdk.default == undefined) {
    663 					continue
    664 				}
    665 				params[k] = vdk.default
    666 					
    667 			}
    668 			if (params[k] === null) {
    669 				if (vdk.required) {
    670 					throw new Error(`Cannot delete required parameter ("${k}")`)
    671 				}
    672 				delete obj[k]
    673 				continue
    674 			}
    675 			if (typeof vdk.type === "string") {
    676 				if (typeof params[k] !== vdk.type) {
    677 					throw new Error(`Invalid value for "${k}" parameter (type was ${typeof params[k]} when expecting ${vdk.type}`)
    678 				}
    679 				obj[k] = params[k]
    680 			}
    681 			if (typeof vdk.parse === "function") {
    682 				obj[k] = vdk.parse(params[k])
    683 			} else if (typeof vdk.validate === "function") {
    684 				if (!vdk.validate(params[k])) {
    685 					throw new Error(`Invalid value for "${k}" parameter ("${params[k]}")`)
    686 				}
    687 				obj[k] = params[k]
    688 			} else if (typeof vdk.type !== "string") {
    689 				throw new Error(`"${k}" parameter missing type constraint, or validate or parse function`)
    690 			}
    691 		}
    692 
    693 		for (let k in vd) {
    694 			if (vd[k].required && obj[k] === undefined) {
    695 				console.warn(params, obj)
    696 				throw new Error(`Cannot omit required parameter ("${k}")`)
    697 			}
    698 		}
    699 
    700 		return obj
    701 	}
    702 
    703 	reqObj(id) {
    704 		let obj = this.obj(id)
    705 		if (obj == null) {
    706 			throw new Error(id + " doesn't exist")
    707 		}
    708 		return obj
    709 	}
    710 
    711 	obj(id) {
    712 		return this.cache[idTable(id)][id]
    713 	}
    714 
    715 	cb(name, arg) {
    716 		if (this.callbacks[name]) {
    717 			console.debug("Backend.cb", name, arg)
    718 			this.callbacks[name](arg)
    719 		}
    720 	}
    721 
    722 	push() {
    723 		if (!this.floorplan) {
    724 			return Promise.resolve()
    725 		}
    726 		// WARNING: This needs a lock
    727 
    728 		let put = (this.history.truncated &&
    729 		    (!this.lastUpdated || this.lastUpdated < this.history.truncated))
    730 
    731 		this.lastUpdated = Date.now()
    732 
    733 		if (put) {
    734 			return this.putServer()
    735 		}
    736 
    737 		let backend = this
    738 		const locked = function() {
    739 			let newpos = backend.history.place
    740 			let dirty = backend.history.between(backend.serverPosition, newpos)
    741 			if (dirty.length === 0) {
    742 				console.log("Not updating server: already up to date")
    743 				return Promise.resolve()
    744 			}
    745 
    746 			let patch = []
    747 			for (let i in dirty) {
    748 				let op = dirty[i].op
    749 				let id = parsePath(dirty[i].path)
    750 				let value = dirty[i].value ? backend.remapIDsValue(dirty[i].value, backend.serverIDs) : null
    751 				if (op === "new" || backend.serverIDs[id] == null) {
    752 					patch.push({ op: "new", path: dirty[i].path, value: value })
    753 				} else {
    754 					patch.push({ op: op, path: idPath(backend.serverIDs[id]), value })
    755 				}
    756 			}
    757 
    758 			console.debug("Backend.push (patch)", patch)
    759 
    760 			return api.fetch("PATCH", backend.endpoint, patch)
    761 				.then(function(data) {
    762 					for (let i = 0; i < patch.length; ++i) {
    763 						if (patch[i].op === "remove") {
    764 							let id = parsePath(patch[i].path)
    765 							backend.unmapID(id)
    766 						}
    767 					}
    768 
    769 					backend.serverPosition = newpos
    770 					updateIDs(backend, data)
    771 					for (let i in dirty) {
    772 						delete dirty[i].dirty
    773 					}
    774 					backend.cb("push")
    775 				})
    776 				.catch(function(err) {
    777 					console.error("Unable to PATCH floorplan, trying PUT", err)
    778 					return backend.putServer()
    779 				})
    780 		}
    781 		return this.lock.acquire("data", locked)
    782 	}
    783 
    784 	putServer() {
    785 		if (!this.floorplan) {
    786 			return Promise.resolve()
    787 		}
    788 
    789 		// WARNING: This needs a lock
    790 		let backend = this
    791 
    792 		return this.lock.acquire("data", function() {
    793 			return api.fetch("PUT", backend.endpoint, backend.cache)
    794 				.then(function(data) {
    795 					for (let k in backend.serverIDs) {
    796 						if (backend.serverIDs[k] !== null) {
    797 							backend.unmapID(backend.serverIDs[k])
    798 						}
    799 					}
    800 					updateIDs(backend, data)
    801 					backend.serverPosition = backend.history.place
    802 					backend.cb("push")
    803 				})
    804 				.catch(function(err) {
    805 					if (!(err instanceof api.FetchError)) {
    806 						console.error(err, "Not a fetch error; undoing and trying again")
    807 						backend.undo()
    808 						return backend.push()
    809 					}
    810 					backend.cb("pusherror", err)
    811 					throw err
    812 				})
    813 		})
    814 	}
    815 
    816 	/*
    817 	 * Pull updates from the server.
    818 	 * (Set AddData diff option to false, and call mark()
    819 	 * once at the end.)
    820 	 */
    821 	pull() {
    822 		if (!this.floorplan) {
    823 			return Promise.resolve()
    824 		}
    825 
    826 		// WARNING: This probably needs a lock
    827 
    828 		// Since we set serverPosition below
    829 		if (this.history.place != this.serverPosition) {
    830 			throw new Error("Push updates first")
    831 		}
    832 
    833 		let backend = this
    834 		return this.lock.acquire("data", function() {
    835 			return api.fetch("GET", backend.endpoint)
    836 				.then(function(data) {
    837 					data = backend.toLocalIDs(data)
    838 					let diff = gendiff("", backend.cache, data)
    839 					console.debug("Backend.Pull (diff)", diff)
    840 					backend.applyDiff(diff, { clean: true })
    841 					backend.cb("pull")
    842 					backend.serverPosition = backend.history.place
    843 				})
    844 		})
    845 	}
    846 
    847 	applyDiff(diff, options) {
    848 		options = options ?? {}
    849 		if (!options.nodiff) {
    850 			this.history.mark()
    851 		}
    852 		for (let i in diff) {
    853 			let id = parsePath(diff[i].path)
    854 			if (diff[i].op === "remove") {
    855 				this.removeData(id, options)
    856 			} else {
    857 				this.addData(id, diff[i].value, options)
    858 			}
    859 			this.cb("patch", diff[i])
    860 		}
    861 		if (!options.nodiff) {
    862 			this.history.mark()
    863 		}
    864 	}
    865 
    866 	updateMappedPoints(a, b, pointmap) {
    867 		const update = function(backend, a, b, pointmap) {
    868 			if (!backend.mappedPoints[a]) {
    869 				backend.mappedPoints[a] = {}
    870 			}
    871 			if (pointmap == null) {
    872 				delete backend.mappedPoints[a][b]
    873 				let id
    874 				for (id in backend.mappedPoints[a]) {
    875 					break
    876 				}
    877 				if (id == null) {
    878 					delete backend.mappedPoints[a]
    879 				}
    880 			} else {
    881 				backend.mappedPoints[a][b] = pointmap
    882 			}
    883 		}
    884 		update(this, a, b, pointmap)
    885 		update(this, b, a, pointmap)
    886 		console.debug("Backend.updateMappedPoints", `Set ${a}+${b} to ${pointmap}`)
    887 		console.debug("Backend.updateMappedPoints", this.mappedPoints)
    888 	}
    889 
    890 	toLocalIDs(data) {
    891 		return this.remapIDs(data, this.localIDs, { createLocal: true })
    892 	}
    893 
    894 	toServerIDs(data) {
    895 		return this.remapIDs(data, this.serverIDs)
    896 	}
    897 
    898 	remapIDs(data, idMap) {
    899 		let newdata = {}
    900 		for (let t in data) {
    901 			newdata[t] = {}
    902 			for (let id in data[t]) {
    903 				let nid = idMap[id]
    904 				if (nid == null) {
    905 					if (idMap == this.localIDs) {
    906 						nid = this.newID(objectTypes[t], id)
    907 					} else {
    908 						// For my purposes this will be fine.
    909 						console.warn("backend.remapIDs", "Not remapping; cannot create server ID")
    910 						nid = id
    911 					}
    912 				}
    913 				newdata[t][nid] = this.remapIDsValue(data[t][id], idMap)
    914 			}
    915 		}
    916 		return newdata
    917 	}
    918 
    919 	remapIDsValue(value, newids) {
    920 		value = structuredClone(value)
    921 		let keys = ['a', 'b', 'furniture_id']
    922 
    923 		for (let i in keys) {
    924 			let id = value[keys[i]]
    925 			if (id == null) {
    926 				continue
    927 			}
    928 			if (newids[id] == null) {
    929 				if (newids != this.localIDs) {
    930 					continue
    931 				}
    932 				let map = this.newID(idType(value[keys[i]]), id)
    933 			}
    934 			value[keys[i]] = newids[id]
    935 		}
    936 		return value
    937 	}
    938 
    939 	whichPointMap(a, b) {
    940 		if (!this.mappedPoints[a]) {
    941 			return undefined
    942 		}
    943 		return this.mappedPoints[a][b]
    944 	}
    945 
    946 	removeOrphans() {
    947 		// The issue is that the origin point isn't stored
    948 		// on the server, so it's kind of randomized each time
    949 		// you fetch the data anew. Furthermore it means you're
    950 		// kind of stuck with the origin point even if you end
    951 		// up wanting to make a new place you're origin for
    952 		// whatever reason.
    953 		//   This will probably just be removed at some point
    954 		// but I'm not entirely sure yet.
    955 		console.error("removeOrphans is currently disabled")
    956 		return
    957 
    958 		let origin = this.originPoint()
    959 		if (origin == undefined) {
    960 			return
    961 		}
    962 
    963 		let connected = this.connected(origin)
    964 		let again = false
    965 		for (let id in this.cache.points) {
    966 			if (!connected[id]) {
    967 				this.removePoint(id, { unmap: true })
    968 				again = true
    969 			}
    970 		}
    971 		if (again) {
    972 			this.removeOrphans()
    973 		}
    974 	}
    975 
    976 	originPoint() {
    977 		for (let i in this.history.diffs) {
    978 			let id = parsePath(this.history.diffs[i].path)
    979 			if (idType(id) === "pnt" && this.cache.points[id] != undefined) {
    980 				return id
    981 			}
    982 		}
    983 
    984 		return undefined
    985 	}
    986 
    987 	connected(p, map) {
    988 		if (!map) {
    989 			map = {}
    990 		}
    991 		map[p] = true
    992 		for (let other in this.mappedPoints[p]) {
    993 			if (!map[other]) {
    994 				this.connected(other, map)
    995 			}
    996 		}
    997 		return map
    998 	}
    999 
   1000 	newID(type, serverID) {
   1001 		let local = uniqueKey(type + "_", this.serverIDs)
   1002 		console.debug("Backend.newID", local)
   1003 		if (serverID != null) {
   1004 			this.mapID(local, serverID)
   1005 		}
   1006 		return local
   1007 	}
   1008 
   1009 	mapID(localID, serverID, options) {
   1010 		options = options ?? {}
   1011 
   1012 		console.debug("Backend.mapID", localID, serverID)
   1013 		if (localID == null || serverID == null) {
   1014 			throw new Error("Requires local and server ID")
   1015 		}
   1016 
   1017 		for (let lid in this.serverIDs) {
   1018 			let sid = this.serverIDs[lid]
   1019 			if (sid != null && this.localIDs[sid] !== lid) {
   1020 				console.error("Corrupt ID map", structuredClone({ bsid: sid, blid: lid, server: this.serverIDs, local: this.localIDs }))
   1021 				throw new Error("ID map is corrupted")
   1022 			}
   1023 		}
   1024 		for (let sid in this.localIDs) {
   1025 			let lid = this.localIDs[sid]
   1026 			if (lid == null || this.serverIDs[lid] !== sid) {
   1027 				console.error("Corrupt ID map x", structuredClone({ bsid: sid, blid: lid, server: this.serverIDs, local: this.localIDs }))
   1028 				throw new Error("ID map is corrupted")
   1029 			}
   1030 		}
   1031 
   1032 		if (options.remap) {
   1033 			this.serverIDs[localID] = null
   1034 		} else {
   1035 			if (this.serverIDs[localID] != undefined) {
   1036 				throw new Error("That local ID is already mapped to " + this.serverIDs[localID])
   1037 			}
   1038 			if (this.localIDs[serverID] != undefined) {
   1039 				throw new Error("That server ID is already mapped to " + this.localIDs[serverID])
   1040 			}
   1041 		}
   1042 		this.localIDs[serverID] = localID
   1043 		this.serverIDs[localID] = serverID
   1044 	}
   1045 
   1046 	unmapID(serverID) {
   1047 		console.debug("Backend.unmapID", serverID)
   1048 		let local = this.localIDs[serverID]
   1049 		if (local == null) {
   1050 			console.warn(serverID + ": Expected mapped id (continuing)")
   1051 		}
   1052 		this.serverIDs[local] = null
   1053 		delete this.localIDs[serverID]
   1054 	}
   1055 
   1056 	remapID(localID, serverID, options) {
   1057 		options = options ?? {}
   1058 		options.remap = true
   1059 		return this.mapID(localID, serverID, options)
   1060 	}
   1061 }
   1062 
   1063 function gendiff(path, a, b) {
   1064 	let diffs = []
   1065 
   1066 	for (let ak in a) {
   1067 		let p = path + "/" + ak
   1068 		if (!b[ak]) {
   1069 			diffs.push({ op: "remove", path: p })
   1070 		} else if (typeof a === "object") {
   1071 			diffs = diffs.concat(gendiff(p, a[ak], b[ak]))
   1072 		} else if (a[ak] != b[ak]) {
   1073 			diffs.push({ op: "replace", path: p, value: b[ak] })
   1074 		}
   1075 	}
   1076 	for (let bk in b) {
   1077 		if (!a[bk]) {
   1078 			diffs.push({ op: "add", path: path + "/" + bk, value: b[bk] })
   1079 		}
   1080 	}
   1081 
   1082 	return diffs
   1083 }
   1084 
   1085 function updateIDs(backend, newdata) {
   1086 	for (let t in newdata) {
   1087 		for (let srvID in newdata[t]) {
   1088 			let x = newdata[t][srvID]
   1089 			if (x.old_id != null) {
   1090 				backend.remapID(x.old_id, srvID)
   1091 			} else if (!backend.localIDs[srvID]) {
   1092 				backend.mapID(backend.newID(idType(srvID)), srvID)
   1093 			}
   1094 		}
   1095 	}
   1096 }
   1097 
   1098 function uniqueKey(prefix, obj) {
   1099 	if (sequences[prefix] == undefined) {
   1100 		sequences[prefix] = 0
   1101 	}
   1102 
   1103 	let key
   1104 	do {
   1105 		key = prefix + sequences[prefix]++
   1106 	} while (obj[key] !== undefined)
   1107 
   1108 	// Wonder if there's an atomic way of testing whether a key is undefined and doing this?
   1109 	// Doesn't matter much for my purposes probably.
   1110 	obj[key] = null
   1111 	return key
   1112 }
   1113 
   1114 export function parseID(s) {
   1115 	let a = s.split("_")
   1116 	if (a.length != 2) {
   1117 		throw new Error(s + ": Invalid ID")
   1118 	}
   1119 	return makeID(a[0], a[1])
   1120 }
   1121 
   1122 function makeID(type, seq) {
   1123 	if (!type || !seq || objectPaths[type] == null || isNaN(seq = Number(seq))) {
   1124 		throw new Error(s + ": Invalid ID")
   1125 	}
   1126 	return { type, seq }
   1127 }
   1128 
   1129 export function idString(id) {
   1130 	if (id.type == null || id.seq == null) {
   1131 		throw new Error("Invalid ID")
   1132 	}
   1133 	return id.type + "_" + id.seq
   1134 }
   1135 
   1136 export function idType(id) {
   1137 	return parseID(id).type
   1138 }
   1139 
   1140 export function idTable(id) {
   1141 	return objectPaths[idType(id)]
   1142 }
   1143 
   1144 export function idPath(id) {
   1145 	let table = idTable(id)
   1146 	if (table == null) {
   1147 		throw new Error("Invalid ID type")
   1148 	}
   1149 	return `/${table}/${id}`
   1150 }
   1151 
   1152 export function parsePath(path) {
   1153 	let a = path.split("/")
   1154 	if (a.length != 3) {
   1155 		throw new Error(path + ": Invalid path")
   1156 	}
   1157 	if (objectTypes[a[1]] == null) {
   1158 		throw new Error(path + ": Invalid path")
   1159 	}
   1160 	let id = parseID(a[2])
   1161 	if (id.type != objectTypes[a[1]]) {
   1162 		throw new Error(path + ": Invalid path for type")
   1163 	}
   1164 	return idString(id)
   1165 }
   1166 
   1167 function parseSize(size) {
   1168 	let n = parseInt(size)
   1169 	if (n <= 0) {
   1170 		throw new Error("Size must be greater than 0")
   1171 	}
   1172 	return n
   1173 }
   1174 
   1175 function parseInt(pos) {
   1176 	let n = Math.round(pos)
   1177 	if (isNaN(n)) {
   1178 		throw new Error("Invalid integer (NaN)")
   1179 	}
   1180 	return n
   1181 }