From abb118688a189edd43a7a6d17188616cb8348354 Mon Sep 17 00:00:00 2001 From: Denis H Date: Tue, 1 Aug 2023 17:29:40 +0200 Subject: [PATCH] Persist and sync across clients text annotations --- app/app.js | 34 ++++++- app/views/scripts/components/ProjectPage.vue | 64 +++++++------ app/views/scripts/store/synced.js | 25 ------ package-lock.json | 95 +++++++++++++++++++- package.json | 4 +- 5 files changed, 164 insertions(+), 58 deletions(-) delete mode 100644 app/views/scripts/store/synced.js diff --git a/app/app.js b/app/app.js index 5e64a89..ec60a60 100644 --- a/app/app.js +++ b/app/app.js @@ -10,7 +10,9 @@ const https = require('https'); const http = require('http'); const morgan = require('morgan'); const nwl = require('neuroweblab'); +const { reduce, assign } = require('lodash'); const microdrawWebsocketServer = require('./controller/microdrawWebsocketServer/microdrawWebsocketServer.js'); +const { Server: HocuspocusServer } = require('@hocuspocus/server'); const routes = require('./routes/routes'); let port; let server; @@ -96,7 +98,6 @@ const start = async function () { microdrawWebsocketServer.initSocketConnection(); }); - // CORS app.use(function (req, res, next) { // Website you wish to allow to connect @@ -156,12 +157,41 @@ const start = async function () { }); global.authTokenMiddleware = nwl.authTokenMiddleware; + + // CRDT backend + + const hocuspocusServer = HocuspocusServer.configure({ + port: 8081, + async onDisconnect(data) { + const project = await app.db.queryProject({shortname: data.documentName}); + const vectorialAnnotations = project.annotations.list.filter((annotation) => annotation.type === 'vectorial'); + const textAnnotations = project.annotations.list.filter((annotation) => annotation.type === 'text'); + const files = data.document.getArray('files').toJSON(); + const newTextAnnotations = textAnnotations.map((annotation) => { + const {name} = annotation; + const valueByFile = reduce(files.map((file) => ({ [file.source]: file[name] })), (result, obj) => assign(result, obj), {}); + + return { + ...annotation, + values: valueByFile + }; + }); + project.annotations.list = [...newTextAnnotations, ...vectorialAnnotations]; + console.log(JSON.stringify(project)); + app.db.updateProject(project); + } + } + ); + + hocuspocusServer.listen(); + + /* setup GUI routes */ routes(app); // catch 404 and forward to error handler - app.use(function (req, res, next) { + app.use(function (req) { console.log('ERROR: File not found', req.url); // var err = new Error('Not Found'); //, req); // err.status = 404; diff --git a/app/views/scripts/components/ProjectPage.vue b/app/views/scripts/components/ProjectPage.vue index 92546a8..2df4f74 100644 --- a/app/views/scripts/components/ProjectPage.vue +++ b/app/views/scripts/components/ProjectPage.vue @@ -9,8 +9,9 @@ import { forEach, get, set } from "lodash"; - import { initSyncedStore, waitForSync } from "../store/synced"; import useVisualization from "../store/visualization"; - import { enableVueBindings } from "@syncedstore/core"; + import { HocuspocusProvider } from "@hocuspocus/provider"; + import { syncedStore, getYjsDoc, enableVueBindings } from "@syncedstore/core"; import Tools from "./Tools.vue"; import { Editor, @@ -53,12 +54,20 @@ } from "nwl-components"; import * as Vue from "vue"; - const { store, webrtcProvider, doc } = initSyncedStore(projectInfo.shortname); - const { baseURL } = Vue.inject('config'); - // make SyncedStore use Vuejs internally enableVueBindings(Vue); - + + const store = syncedStore({ files: [], fragment: "xml" }); + const doc = getYjsDoc(store); + + const crdtProvider = new HocuspocusProvider({ + url: "ws://0.0.0.0:8081", // FIXME + name: projectInfo.shortname, + document: doc + }); + + const { baseURL } = Vue.inject('config'); + const props = defineProps({ project: { type: Object, @@ -71,12 +80,7 @@ }); const linkPrefix = `${baseURL}/project/${projectInfo.shortname}?source=` - const files = Vue.ref(projectInfo.files.list); const selectedFileIndex = projectInfo.files.list.findIndex(file => file.source === props.selectedFile); - doc.getArray("files").observe(() => { - files.value.splice(0, files.value.length); - files.value.push(...store.files); - }); const textAnnotations = projectInfo.annotations.list.filter(anno => anno.type !== 'vectorial'); const volumeAnnotations = projectInfo.annotations.list.filter(anno => anno.type === 'vectorial'); @@ -102,6 +106,10 @@ const keys = new Map(); keys.set("Name", "name"); keys.set("File", "source"); + forEach(textAnnotations, (annotation) => { + if (annotation.display) + keys.set(annotation.name, `${annotation.name}`); + }); return keys; }; @@ -113,19 +121,14 @@ return keys; }; - const syncMicrodraw = () => { - console.log('sync microdraw') - } - const valueChange = (content, index, selector) => { const sel = typeof selector === "string" ? [index, selector] : [index, ...selector]; - set(store.files, sel, content); - syncMicrodraw(); + set(store.files, sel, content); }; const selectFile = async (file) => { - window.location = `${linkPrefix}${file.source}` + // No-op. We'd rather let user click on a link. } const setupKeyDownListeners = () => { @@ -137,7 +140,7 @@ return; } if (selectedTr.previousElementSibling) { - selectedTr.previousElementSibling.click(); + selectedTr.previousElementSibling.querySelector('a[href]').click(); } break; case "ArrowDown": @@ -145,7 +148,7 @@ return; } if (selectedTr.nextElementSibling) { - selectedTr.nextElementSibling.click(); + selectedTr.nextElementSibling.querySelector('a[href]').click(); } break; default: @@ -153,11 +156,7 @@ } }); }; - - const delay = (ms) => { - return new Promise((resolve) => setTimeout(resolve, ms)); - }; - + const handleLayoutChange = () => { Microdraw.resizeAnnotationOverlay(); }; @@ -187,7 +186,18 @@ Vue.onMounted(async () => { setupKeyDownListeners(); - await waitForSync(webrtcProvider); + crdtProvider.on('synced', () => { + console.log('on synced'); + if (store.files.length === 0) { + store.files.push(...projectInfo.files.list); + forEach(textAnnotations, (annotation) => { + forEach(annotation.values, (value, source) => { + const file = store.files.find(file => file.source === source); + if (file) file[annotation.name] = value; + }); + }); + } + }); await initVisualization(); window.addEventListener('resize', handleResize); }); diff --git a/app/views/scripts/store/synced.js b/app/views/scripts/store/synced.js deleted file mode 100644 index 4774f47..0000000 --- a/app/views/scripts/store/synced.js +++ /dev/null @@ -1,25 +0,0 @@ -import { getYjsValue, syncedStore } from '@syncedstore/core'; -import { WebrtcProvider } from 'y-webrtc'; - -export const initSyncedStore = (identifier) => { - const store = syncedStore({ files: [], fragment: 'xml' }); - - // Get the Yjs document and sync automatically using y-webrtc - const doc = getYjsValue(store); - // eslint-disable-next-line - const webrtcProvider = new WebrtcProvider(identifier, doc); - - return { store, webrtcProvider, doc }; -}; - -// not the prettiest way to poll for sync, but will do for now -export const waitForSync = (provider) => new Promise((resolve) => { - const int = setInterval(() => { - if (provider.room !== null) { - if (provider.room.synced || provider.room.webrtcConns.size === 0) { - clearInterval(int); - resolve(); - } - } - }, 500); -}); diff --git a/package-lock.json b/package-lock.json index 4c082be..1431aec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.0.1", "license": "GPL-3.0", "dependencies": { - "@syncedstore/core": "^0.4.1", + "@hocuspocus/provider": "^2.2.3", + "@hocuspocus/server": "^2.2.3", + "@syncedstore/core": "^0.4.3", "bcrypt": "^3.0.8", "body-parser": "^1.18.3", "cross-env": "^7.0.3", @@ -113,6 +115,74 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@hocuspocus/common": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-2.2.3.tgz", + "integrity": "sha512-7FxNUGnA8dNgXgve2ranrRtA2px7+K4EAsP21pvv/mbQuYkU4g+ciD32jywZsmshxsPaa/+nr0j62tkDXVjA5Q==", + "dependencies": { + "lib0": "^0.2.47" + } + }, + "node_modules/@hocuspocus/provider": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-2.2.3.tgz", + "integrity": "sha512-r/kMzXwJLL1+Vd5SOD7CYqBr9/bK1hm5445gs3/+ss9Wt8zH1JVE2AtK5shNc+3VYpyNb3MAvdZx5OKx9JaSPg==", + "dependencies": { + "@hocuspocus/common": "^2.2.3", + "@lifeomic/attempt": "^3.0.2", + "lib0": "^0.2.47", + "ws": "^7.5.9" + }, + "peerDependencies": { + "y-protocols": "^1.0.5", + "yjs": "^13.5.29" + } + }, + "node_modules/@hocuspocus/server": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@hocuspocus/server/-/server-2.2.3.tgz", + "integrity": "sha512-NFU9vXGo1IHkFluh+rW4IaOKPgxAgovJRL+RwJj6c8oTJSdLpayoUWnNIEMpZ7Jo68wZLMp6Sp3ib0NY98c/WQ==", + "dependencies": { + "@hocuspocus/common": "^2.2.3", + "async-lock": "^1.3.1", + "kleur": "^4.1.4", + "lib0": "^0.2.47", + "uuid": "^9.0.0", + "ws": "^8.5.0" + }, + "peerDependencies": { + "y-protocols": "^1.0.5", + "yjs": "^13.5.29" + } + }, + "node_modules/@hocuspocus/server/node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@hocuspocus/server/node_modules/ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.9.3", "dev": true, @@ -183,6 +253,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@lifeomic/attempt": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.0.3.tgz", + "integrity": "sha512-GlM2AbzrErd/TmLL3E8hAHmb5Q7VhDJp35vIbyPVA5Rz55LZuRr8pwL3qrwwkVNo05gMX1J44gURKb4MHQZo7w==" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.8", "license": "BSD-3-Clause", @@ -1100,6 +1175,11 @@ "version": "3.2.3", "license": "MIT" }, + "node_modules/async-lock": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.0.tgz", + "integrity": "sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==" + }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" @@ -4167,6 +4247,14 @@ "node": ">=0.10.0" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/kruptein": { "version": "3.0.3", "license": "MIT", @@ -8081,8 +8169,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "7.5.6", - "license": "MIT", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", "engines": { "node": ">=8.3.0" }, diff --git a/package.json b/package.json index d1a8ee6..27c417b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "dev-pages": "webpack --mode development --config webpack.pages.config.js && cp app/views/scripts/dist/*-page.js app/public/js/pages/" }, "dependencies": { - "@syncedstore/core": "^0.4.1", + "@hocuspocus/provider": "^2.2.3", + "@hocuspocus/server": "^2.2.3", + "@syncedstore/core": "^0.4.3", "bcrypt": "^3.0.8", "body-parser": "^1.18.3", "cross-env": "^7.0.3",