commit 53f6b3bd0a6594f585ff52eca056419de107dcd4
parent f9b5af1e3415f5cd9b605c3f34630184dad34959
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date: Fri, 16 Aug 2024 18:22:11 -0700
Push and pull updates to and from the server
Diffstat:
6 files changed, 315 insertions(+), 49 deletions(-)
diff --git a/files/floorplans/floorplan/backend.js b/files/floorplans/floorplan/backend.js
@@ -1,5 +1,28 @@
+import * as api from "/lib/api.js"
+
export class FloorplanBackend {
- constructor() {
+ constructor(floorplan, options) {
+ if (!options) {
+ options = {}
+ }
+
+ if (!floorplan || !floorplan.user || !floorplan.name) {
+ throw new Error("Requires floorplan")
+ }
+ this.floorplan = floorplan
+
+ if (!options.server) {
+ // This does nothing at the moment
+ this.server = "https://api.spaceplanner.app"
+ } else {
+ this.server = options.server
+ }
+
+
+ if (options.callbacks) {
+ this.callbacks = options.callbacks
+ }
+
// Cache for server (both from and to)
this.cache = {
// { pointId: { x: Number, y: Number } }
@@ -51,7 +74,7 @@ export class FloorplanBackend {
* diff: <JSON Patch>
* }
* [...]
- * }
+ * ]
*/
this.diffs = []
@@ -59,6 +82,10 @@ export class FloorplanBackend {
this.diff = null
}
+ get endpoint() {
+ return "floorplans/" + this.floorplan.user + "/" + this.floorplan.name + "/data"
+ }
+
// Start writing new differences to a new diff set
newDiff() {
if (this.diffs.length > 0 && this.diffs[this.diff].diff.length === 0) {
@@ -73,11 +100,12 @@ export class FloorplanBackend {
diff: []
}) - 1
console.debug("newDiff", this.diff)
+ this.cb("newdiff")
return this.diff
}
// Add to current diff
- addToDiff(op, path, value, dirty) {
+ addToDiff(op, path, value, options) {
if (!op || !path) {
throw new Error("Requires op and path")
}
@@ -98,9 +126,12 @@ export class FloorplanBackend {
value: value,
time: Date.now()
}
- if (dirty) {
+ if (!options.clean) {
diff.dirty = true
}
+ if (options.new) {
+ diff.new = true
+ }
this.diffs[this.diff].diff.push(diff)
console.debug("Backend.addToDiff", diff)
}
@@ -117,21 +148,13 @@ export class FloorplanBackend {
// to get from time1 to time2
updatesBetween(time1, time2) {
let updates = []
- let reverse = true
+ let reverse = false
if (this.diffs.length === 0 || this.diffs[0].length === 0) {
return []
}
- if (!time1) {
- time1 = this.diffs[0].diff[0].time
- }
- if (!time2) {
- // Could use Date.now() I suppose
- time2 = this.diffs.at(-1).diff.at(-1).time
- }
-
- if (time1 > time2) {
+ if (time1 && time2 && time1 > time2) {
reverse = !reverse
let t = time1
time1 = time2
@@ -141,9 +164,9 @@ export class FloorplanBackend {
for (let i in this.diffs) {
for (let j in this.diffs[i].diff) {
let diff = this.diffs[i].diff[j]
- if (diff.time >= time1) {
+ if (!time1 || diff.time >= time1) {
updates.push(diff)
- } else if (diff.time > time2) {
+ } else if (time2 && diff.time > time2) {
return reverse ? updates.reverse() : updates
}
}
@@ -160,7 +183,7 @@ export class FloorplanBackend {
*/
addData(type, value, key, options) {
if (!options) {
- options = { diff: true, clean: false }
+ options = {}
}
if (!key) {
@@ -172,27 +195,33 @@ export class FloorplanBackend {
*/
key = uniqueKey(this.cache[type])
}
+
console.debug("Backend.addData", type, key, value)
+ if (!this.cache[type][key]) {
+ options.new = true
+ }
this.cache[type][key] = value
// May want to use replace op if it's appropriate.
- if (options.diff) {
- this.addToDiff("add", diffPath(type, key), this.cache[type][key], !options.clean)
+ // Doing this first so it can set new appropriately.
+ if (!options.nodiff) {
+ this.addToDiff("add", diffPath(type, key), this.cache[type][key], options)
}
+
return key
}
removeData(type, key, options) {
if (!options) {
- options = { diff: true, clean: false }
+ options = {}
}
console.debug("Backend.removeData", type, key)
if (!this.cache[type][key]) {
throw new Error("Expected " + key + " to exist")
}
- if (options.diff) {
- this.addToDiff("remove", diffPath(type, key), null, !options.clean)
+ if (!options.nodiff) {
+ this.addToDiff("remove", diffPath(type, key), null, options)
}
delete this.cache[type][key]
}
@@ -218,6 +247,8 @@ export class FloorplanBackend {
if (!this.cache.points[a] || !this.cache.points[b]) {
throw new Error("Pointmap must reference existing points")
}
+
+ // NOTE: For now, a and b are numbers. May not always be the case
return this.addData("pointmaps", {
type: type,
a: a,
@@ -232,7 +263,7 @@ export class FloorplanBackend {
reqId(type, id) {
let obj = this.byId(type, id)
if (!obj) {
- throw new Error(id + " for " + type + "doesn't exist")
+ throw new Error(id + " for " + type + " doesn't exist")
}
return obj
}
@@ -244,29 +275,156 @@ export class FloorplanBackend {
return this.cache[type][id]
}
+ dirty() {
+ let a = []
+ for (let i = 0; i <= this.diff; ++i) {
+ for (let diff in this.diffs[i].diff) {
+ if (this.diffs[i].diff[diff].dirty) {
+ a.push(this.diffs[i].diff[diff])
+ }
+ }
+ }
+ console.debug("Backend.dirty", a)
+ return a
+ }
+
+ cb(name, arg) {
+ if (this.callbacks[name]) {
+ this.callbacks[name](arg)
+ }
+ }
+
// Push updates to the server
- //push() {}
+ push() {
+ // Need a method of making sure we're only sending these once...
+ let dirty = this.dirty()
+ let patch = []
+
+ for (let i in dirty) {
+ let op
+ if (dirty[i].op != "add") {
+ op = dirty[i].op
+ } else {
+ if (dirty[i].new) {
+ op = "new"
+ } else {
+ op = "replace"
+ }
+ }
+ patch.push( { op: op, path: dirty[i].path, value: dirty[i].value })
+
+ let ref = parsePath(dirty[i].path)
+ if (ref.type === "pointmaps") {
+ dirty[i].value.a = Number(dirty[i].value.a)
+ dirty[i].value.b = Number(dirty[i].value.b)
+ }
+ }
+
+ console.debug("Backend.push (patch)", patch)
+
+ let backend = this
+ api.fetch("PATCH", this.endpoint, patch)
+ .then(function(data) {
+ updateIds(backend, data)
+ for (let i in dirty) {
+ delete dirty[i].dirty
+ delete dirty[i].new
+ }
+ backend.cb("push")
+ })
+ }
/*
* Pull updates from the server.
* (Set AddData diff option to false, and call newDiff()
* once at the end.)
*/
- //pull() {}
+ pull() {
+ let backend = this
+ api.fetch("GET", this.endpoint)
+ .then(function(data) {
+ let diff = gendiff("", backend.cache, data)
+ console.log("Backend.Pull (diff)", diff)
+ backend.newDiff()
+ let options = { clean: true }
+ for (let i in diff) {
+ let ref = parsePath(diff[i].path)
+ if (diff[i].op === "remove") {
+ backend.removeData(ref.type, ref.id, options)
+ } else {
+ backend.addData(ref.type, diff[i].value, ref.id, options)
+ }
+ }
+ backend.newDiff()
+ backend.cb("pull")
+ })
+ }
+}
+
+function gendiff(path, a, b) {
+ let diffs = []
+
+ for (let ak in a) {
+ let p = path + "/" + ak
+ if (!b[ak]) {
+ diffs.push({ op: "remove", path: p })
+ } else if (typeof a === "object") {
+ diffs = diffs.concat(gendiff(p, a[ak], b[ak]))
+ } else if (a[ak] != b[ak]) {
+ diffs.push({ op: "replace", path: p, value: b[ak] })
+ }
+ }
+ for (let bk in b) {
+ if (!a[bk]) {
+ diffs.push({ op: "add", path: path + "/" + bk, value: b[bk] })
+ }
+ }
+
+ return diffs
+}
+
+function updateIds(backend, newdata) {
+ for (let type in newdata) {
+ for (let id in newdata[type]) {
+ let x = newdata[type][id]
+ if (x.old_id) {
+ console.debug("Backend.updateIds", `ID ${x.old_id} > ${id}`)
+ if (backend.cache[type][id]) {
+ throw new Error("ERROR: Pull id conflict")
+ }
+ backend.cache[type][id] = backend.cache[type][x.old_id]
+ // Both old and new exist at the moment, hense;
+ backend.cb("updateId", { type: type, old: x.old_id, new: id })
+ delete backend.cache[type][x.old_id]
+ }
+ }
+ }
}
function diffPath(type, id) {
- return type + "/" + id
+ return "/" + type + "/" + id
+}
+
+export function parsePath(path) {
+ let a = path.split("/")
+ if (a.length != 3) {
+ throw new Error("Invalid path")
+ }
+ return newRef(a[1], a[2])
+}
+
+export function newRef(type, id) {
+ return { type: type, id: id }
}
-function uniqueKey(obj, prefix) {
+function uniqueKey(obj) {
let key
do {
- key = (prefix ? prefix : "") + Math.random().toString().split(".").join("")
+ key = Number(Math.random().toString().split(".").join(""))
} while (obj[key])
// Wonder if there's an atomic way of testing whether a key is undefined and doing this?
// Doesn't matter much for my purposes probably.
- obj[key] = true
+ obj[key] = null
return key
}
diff --git a/files/floorplans/floorplan/editor.js b/files/floorplans/floorplan/editor.js
@@ -1,5 +1,5 @@
import { default as SVG } from "/lib/github.com/svgdotjs/svg.js/svg.js"
-import { FloorplanBackend as Backend } from "./backend.js"
+import * as backend from "./backend.js"
SVG.extend(SVG.Element, {
select: function() {
@@ -41,17 +41,31 @@ SVG.extend(SVG.Circle, {
})
export class FloorplanEditor {
- constructor(svg) {
+ constructor(svg, floorplan, options) {
+ if (!options) {
+ options = {}
+ }
+
this.draw = svg
this.mode
this.modes = {}
this.mode_states = {}
- this.backend = new Backend()
+
+ if (!options.backend) {
+ options.backend = {}
+ }
+ if (!options.backend.callbacks) {
+ options.backend.callbacks = {}
+ }
+
+ let editor = this
+ options.backend.callbacks.updateId = function(ids) { editor.updateId(ids) }
+ this.backend = new backend.FloorplanBackend(floorplan, options.backend)
this.updated = null // last time updated from backend
- let floorplan = this.draw.group().attr({ id: "floorplan" })
- floorplan.group().attr({ id: "walls" }) // lines
- floorplan.group().attr({ id: "points" }) // circles
+ let data = this.draw.group().attr({ id: "floorplan" })
+ data.group().attr({ id: "walls" }) // lines
+ data.group().attr({ id: "points" }) // circles
}
addMode(name, mode) {
@@ -178,11 +192,11 @@ export class FloorplanEditor {
}
updateDisplay() {
- let diffs = this.backend.updatesSince(this.updated + 1)
+ let diffs = this.backend.updatesSince(this.updated ? this.updated + 1 : null)
if (diffs.length === 0) {
return
}
- this.updated = diffs[0].time
+ this.updated = diffs.at(-1).time
this.applyDiff(diffs)
}
@@ -241,7 +255,7 @@ export class FloorplanEditor {
wall.plot(a.x, a.y, b.x, b.y)
} else {
wall = editor.draw.findExactlyOne("#walls")
- .line(a.x, a.y, b.x, b.y).stroke("black")
+ .line(a.x, a.y, b.x, b.y).stroke("black").attr({ id: name })
}
}
},
@@ -260,21 +274,27 @@ export class FloorplanEditor {
throw new Error("Unexpected patch operation")
}
- let path = diff.path.split("/")
- if (path.length != 2) {
- throw new Error("Expected only two path elements")
- }
- let type = path[0]
- let id = path[1]
+ let ref = backend.parsePath(diff.path)
let op = reverse ? reverseOps[diff.op] : diff.op
- if (!ops[op][type]) {
+ if (!ops[op][ref.type]) {
throw new Error("Unhandled patch")
}
- ops[op][type](type + "_" + id, diff.value)
+ ops[op][ref.type](refId(ref), diff.value)
+ }
+
+ updateId(ids) {
+ let e = this.findRef(backend.newRef(ids.type, ids.old))
+ e.attr({ id: refId(backend.newRef(ids.type, ids.new)) })
+ console.log("Editor.updateId", `${ids.old} -> ${ids.new}`)
+ }
+
+ findRef(ref) {
+ return this.draw.findExactlyOne(byId(refId(ref)))
}
}
+
function remove_mode_handlers(target, mode_handlers) {
for (let event in mode_handlers) {
for (let handler in mode_handlers[event]) {
@@ -296,3 +316,7 @@ function add_mode_handlers(target, mode_handlers) {
function byId(id) {
return "#" + id
}
+
+function refId(ref) {
+ return ref.type + "_" + ref.id
+}
diff --git a/files/floorplans/floorplan/main.css b/files/floorplans/floorplan/main.css
@@ -20,6 +20,11 @@ header > .toolbar {
margin: .25em;
}
+header > .toolbar > li {
+ float: left;
+ padding-right: 1em;
+}
+
svg {
flex: 1;
border: ridge thin;
@@ -50,3 +55,16 @@ svg {
.mode_selector.selected {
border-color: blue;
}
+
+aside.message {
+ position: absolute;
+ box-sizing: border-box;
+ bottom: 0;
+ left: 0;
+ width: 100vw;
+ background-color: rgba(0, 0, 0, 0.6);
+ color: white;
+ border: thin solid darkgrey;
+ border-radius: 0;
+ padding: .2em;
+}
diff --git a/files/floorplans/floorplan/main.js b/files/floorplans/floorplan/main.js
@@ -1,8 +1,11 @@
import { default as SVG } from "/lib/github.com/svgdotjs/svg.js/svg.js"
import "/lib/github.com/svgdotjs/svg.panzoom.js/svg.panzoom.js"
import * as ui from "/lib/ui.js"
+import * as etc from "/lib/etc.js"
import { FloorplanEditor as Editor } from "./editor.js"
+const messageTimeout = 4000
+
const buttons = {
left: 0,
middle: 1,
@@ -10,11 +13,15 @@ const buttons = {
}
function init() {
+ etc.authorize()
+
let floorplan = (new URLSearchParams(new URL(document.URL).search)).get("name")
if (!floorplan) {
document.location.href = "/floorplans"
}
- document.querySelector("h1").textContent = floorplan
+ let h1 = document.querySelector("h1")
+ h1.textContent = floorplan
+ let suffix = h1.appendChild(document.createTextNode(""))
let draw = SVG()
.addTo("#floorplan_container")
@@ -26,7 +33,33 @@ function init() {
zoomFactor: .5
})
- let editor = new Editor(draw)
+ let editor = new Editor(draw,
+ { user: localStorage.getItem("username"), name: floorplan },
+ { backend: {
+ callbacks: {
+ pull: function() {
+ editor.updateDisplay()
+ suffix.data = ""
+ },
+ push: function() {
+ suffix.data = ""
+ },
+ newdiff: function() {
+ suffix.data = "*"
+ editor.updateDisplay()
+ }
+ }
+ }
+ })
+
+ let push = ui.button("Push", "Push updates", "arrow-up",
+ { handlers: { click: function() { editor.backend.push(); notify("Pushed floorplan", "pushpull") } } })
+ let pull = ui.button("Pull", "Pull updates", "arrow-down",
+ { handlers: { click: function() { editor.backend.pull(); notify("Pulled floorplan", "pushpull") } } })
+ let pushpull = document.createElement("li")
+ pushpull.appendChild(pull)
+ pushpull.appendChild(push)
+
for (let mode in modes) {
editor.addMode(mode, modes[mode])
}
@@ -35,7 +68,14 @@ function init() {
let toolbar = document.querySelector("header")
.appendChild(document.createElement("ul"))
toolbar.classList.add("toolbar")
- toolbar.appendChild(modesSelector(editor, "Modes:"))
+
+ toolbar.append(pushpull)
+
+ let li = document.createElement("li")
+ li.append(modesSelector(editor, "Modes:"))
+ toolbar.append(li)
+
+ editor.backend.pull()
}
function modesSelector(editor, text) {
@@ -99,6 +139,7 @@ function addWallHandler(click, editor) {
}
editor.addPoint(editor.draw.point(click.clientX, click.clientY))
+ editor.updateDisplay()
if (editor.draw.findOne("#points").children().length >= 2) {
editor.mapPoints("wall")
}
@@ -110,4 +151,25 @@ function preventDefaultHandler(event) {
event.preventDefault()
}
+function notify(message, id) {
+ console.log("Notify", message)
+
+ let e = document.createElement("aside")
+ e.id = id
+ e.classList.add("message")
+ e.textContent = message
+
+ let old
+ if (id) {
+ old = document.getElementById(id)
+ }
+
+ if (old) {
+ old.replaceWith(e)
+ } else {
+ document.body.prepend(e)
+ }
+ setTimeout(function() { e.remove() }, messageTimeout)
+}
+
window.onload = init
diff --git a/files/icons/arrow-down-outline.svg b/files/icons/arrow-down-outline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><polyline points="112 268 256 412 400 268" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:48px"/><line x1="256" y1="392" x2="256" y2="100" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:48px"/></svg>
+\ No newline at end of file
diff --git a/files/icons/arrow-up-outline.svg b/files/icons/arrow-up-outline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><polyline points="112 244 256 100 400 244" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:48px"/><line x1="256" y1="120" x2="256" y2="412" style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:48px"/></svg>
+\ No newline at end of file