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 }