From e1e661976d2e430e0ce4964f478a714357cbfb92 Mon Sep 17 00:00:00 2001 From: Trevor Buckner Date: Fri, 27 Jun 2025 08:07:02 -0400 Subject: [PATCH 1/7] Initial test --- client/homebrew/pages/editPage/editPage.jsx | 24 ++++++++++++++++++++- package-lock.json | 10 +++++++++ package.json | 1 + scripts/project.json | 3 ++- server/homebrew.api.js | 11 ++++++++-- 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index f2b1e809f..42aa4f651 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -3,6 +3,7 @@ require('./editPage.less'); const React = require('react'); const _ = require('lodash'); const createClass = require('create-react-class'); +import {makePatches, applyPatches, stringifyPatches, parsePatches} from '@sanity/diff-match-patch'; import request from '../../utils/request-middleware.js'; const { Meta } = require('vitreum/headtags'); @@ -251,9 +252,30 @@ const EditPage = createClass({ const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); - const brew = this.state.brew; + const brew = { ...this.state.brew }; + + let jsonString = JSON.stringify(brew); + let bytes = new TextEncoder().encode(jsonString).length; + + console.log(`Before size: ${bytes} bytes (${(bytes / 1024).toFixed(2)} KB)`); + brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1; + brew.patches = makePatches(this.savedBrew.text, brew.text); + brew.text = undefined; + brew.textBin = undefined; + console.log('Saving Brew', brew); + + jsonString = JSON.stringify(brew); + bytes = new TextEncoder().encode(jsonString).length; + + console.log(`After size: ${bytes} bytes (${(bytes / 1024).toFixed(2)} KB)`); + + jsonString = JSON.stringify(brew.patches); + bytes = new TextEncoder().encode(jsonString).length; + + console.log(`Patch size: ${bytes} bytes (${(bytes / 1024).toFixed(2)} KB)`); + const params = `${transfer ? `?${this.state.saveGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''}`; const res = await request .put(`/api/update/${brew.editId}${params}`) diff --git a/package-lock.json b/package-lock.json index 581bd5b30..bd9eca5f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@babel/preset-react": "^7.27.1", "@babel/runtime": "^7.27.1", "@googleapis/drive": "^12.1.0", + "@sanity/diff-match-patch": "^3.2.0", "body-parser": "^2.2.0", "classnames": "^2.5.1", "codemirror": "^5.65.6", @@ -2867,6 +2868,15 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@sanity/diff-match-patch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@sanity/diff-match-patch/-/diff-match-patch-3.2.0.tgz", + "integrity": "sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", diff --git a/package.json b/package.json index 10db28857..811a05f92 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@babel/preset-react": "^7.27.1", "@babel/runtime": "^7.27.1", "@googleapis/drive": "^12.1.0", + "@sanity/diff-match-patch": "^3.2.0", "body-parser": "^2.2.0", "classnames": "^2.5.1", "codemirror": "^5.65.6", diff --git a/scripts/project.json b/scripts/project.json index c384ae1de..340de077c 100644 --- a/scripts/project.json +++ b/scripts/project.json @@ -27,6 +27,7 @@ "codemirror/addon/selection/active-line.js", "codemirror/addon/hint/show-hint.js", "moment", - "superagent" + "superagent", + "@sanity/diff-match-patch" ] } diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 392e175ca..9a2aa8c0a 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -8,6 +8,7 @@ import Markdown from '../shared/naturalcrit/markdown.js'; import yaml from 'js-yaml'; import asyncHandler from 'express-async-handler'; import { nanoid } from 'nanoid'; +import {makePatches, applyPatches, stringifyPatches, parsePatch} from '@sanity/diff-match-patch'; import { splitTextStyleAndMetadata, brewSnippetsToJSON } from '../shared/helpers.js'; import checkClientVersion from './middleware/check-client-version.js'; @@ -337,12 +338,18 @@ const api = { // Initialize brew from request and body, destructure query params, and set the initial value for the after-save method const brewFromClient = api.excludePropsFromUpdate(req.body); const brewFromServer = req.brew; + if(brewFromServer.version && brewFromClient.version && brewFromServer.version > brewFromClient.version) { console.log(`Version mismatch on brew ${brewFromClient.editId}`); res.setHeader('Content-Type', 'application/json'); return res.status(409).send(JSON.stringify({ message: `The brew has been changed on a different device. Please save your changes elsewhere, refresh, and try again.` })); } + console.log(`Brewfromserver: ${JSON.stringify(brewFromServer)}`); + splitTextStyleAndMetadata(brewFromServer); + brewFromClient.text = applyPatches(brewFromClient.patches, brewFromServer.text)[0]; + console.log(`Server Text: ${brewFromServer.text}`); + console.log(`Brew text: ${brewFromClient.text}`); let brew = _.assign(brewFromServer, brewFromClient); const googleId = brew.googleId; const { saveToGoogle, removeFromGoogle } = req.query; @@ -484,8 +491,8 @@ const api = { }; router.post('/api', checkClientVersion, asyncHandler(api.newBrew)); -router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew)); -router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew)); +router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew)); +router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew)); router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew)); router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew)); router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle)); From 869958ec384b45e4008c94b4e51d97b1babe73ff Mon Sep 17 00:00:00 2001 From: Trevor Buckner Date: Mon, 7 Jul 2025 19:00:01 +0000 Subject: [PATCH 2/7] Don't save unless previous save is complete --- client/homebrew/pages/editPage/editPage.jsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 42aa4f651..3e157b056 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -197,7 +197,7 @@ const EditPage = createClass({ trySave : function(immediate=false){ if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT); - if(this.hasChanges()){ + if(this.hasChanges() && !this.state.isSaving){ this.debounceSave(); } else { this.debounceSave.cancel(); @@ -216,8 +216,7 @@ const EditPage = createClass({ confirmGoogleTransfer : !prevState.confirmGoogleTransfer })); this.setState({ - error : null, - isSaving : false + error : null }); }, @@ -233,9 +232,8 @@ const EditPage = createClass({ toggleGoogleStorage : function(){ this.setState((prevState)=>({ saveGoogle : !prevState.saveGoogle, - isSaving : false, error : null - }), ()=>this.save()); + }), ()=>this.trySave(true)); }, save : async function(){ From 43095507eeb94eba1fbc7abea174d3d1db04361c Mon Sep 17 00:00:00 2001 From: Trevor Buckner Date: Mon, 7 Jul 2025 19:26:03 +0000 Subject: [PATCH 3/7] Fix --- client/homebrew/pages/editPage/editPage.jsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 1395c28db..3181df816 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -197,11 +197,14 @@ const EditPage = createClass({ trySave : function(immediate=false){ if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT); - if(this.hasChanges() && !this.state.isSaving){ + if(this.state.isSaving) + return; + + if(this.hasChanges()) this.debounceSave(); - } else { + else this.debounceSave.cancel(); - } + if(immediate) this.debounceSave.flush(); }, From 89ce4de3544a4d9e6a04ef9911fd3bd20f885aa3 Mon Sep 17 00:00:00 2001 From: Trevor Buckner Date: Mon, 7 Jul 2025 20:47:34 +0000 Subject: [PATCH 4/7] Add hash-wasm package for md5 --- package-lock.json | 34 +++++++++++++++------------------- package.json | 1 + 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5556b19f2..3915194cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "express-async-handler": "^1.2.0", "express-static-gzip": "3.0.0", "fs-extra": "11.3.0", + "hash-wasm": "^4.12.0", "idb-keyval": "^6.2.2", "js-yaml": "^4.1.0", "jwt-simple": "^0.5.6", @@ -2874,25 +2875,6 @@ "@noble/hashes": "^1.1.5" } }, - "node_modules/@sanity/diff-match-patch": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@sanity/diff-match-patch/-/diff-match-patch-3.2.0.tgz", - "integrity": "sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2915,6 +2897,15 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@sanity/diff-match-patch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@sanity/diff-match-patch/-/diff-match-patch-3.2.0.tgz", + "integrity": "sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.37", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", @@ -7460,6 +7451,11 @@ "node": ">=4" } }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==" + }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", diff --git a/package.json b/package.json index 40ae66116..3099af3f4 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "express-async-handler": "^1.2.0", "express-static-gzip": "3.0.0", "fs-extra": "11.3.0", + "hash-wasm": "^4.12.0", "idb-keyval": "^6.2.2", "js-yaml": "^4.1.0", "jwt-simple": "^0.5.6", From 4c897fdeb504055f99c8b1e41814201f6c68297d Mon Sep 17 00:00:00 2001 From: Trevor Buckner Date: Mon, 7 Jul 2025 21:00:03 +0000 Subject: [PATCH 5/7] Add MD5 hash check --- client/homebrew/pages/editPage/editPage.jsx | 2 ++ server/homebrew.api.js | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 3181df816..dc2f0f2a5 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -4,6 +4,7 @@ const React = require('react'); const _ = require('lodash'); const createClass = require('create-react-class'); import {makePatches, applyPatches, stringifyPatches, parsePatches} from '@sanity/diff-match-patch'; +import { md5 } from 'hash-wasm'; import request from '../../utils/request-middleware.js'; const { Meta } = require('vitreum/headtags'); @@ -263,6 +264,7 @@ const EditPage = createClass({ brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1; brew.patches = makePatches(this.savedBrew.text, brew.text); + brew.hash = await md5(this.savedBrew.text); brew.text = undefined; brew.textBin = undefined; console.log('Saving Brew', brew); diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 9a2aa8c0a..384031c77 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -9,6 +9,7 @@ import yaml from 'js-yaml'; import asyncHandler from 'express-async-handler'; import { nanoid } from 'nanoid'; import {makePatches, applyPatches, stringifyPatches, parsePatch} from '@sanity/diff-match-patch'; +import { md5 } from 'hash-wasm'; import { splitTextStyleAndMetadata, brewSnippetsToJSON } from '../shared/helpers.js'; import checkClientVersion from './middleware/check-client-version.js'; @@ -338,11 +339,12 @@ const api = { // Initialize brew from request and body, destructure query params, and set the initial value for the after-save method const brewFromClient = api.excludePropsFromUpdate(req.body); const brewFromServer = req.brew; + const serverHash = md5(brewFromServer.text); - if(brewFromServer.version && brewFromClient.version && brewFromServer.version > brewFromClient.version) { + if((brewFromServer?.version !== brewFromClient?.version) || (serverHash !== brewFromClient.hash)) { console.log(`Version mismatch on brew ${brewFromClient.editId}`); res.setHeader('Content-Type', 'application/json'); - return res.status(409).send(JSON.stringify({ message: `The brew has been changed on a different device. Please save your changes elsewhere, refresh, and try again.` })); + return res.status(409).send(JSON.stringify({ message: `The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` })); } console.log(`Brewfromserver: ${JSON.stringify(brewFromServer)}`); From 41daf8d172299fabb16f0d6f916e4ba88599490d Mon Sep 17 00:00:00 2001 From: Trevor Buckner Date: Mon, 7 Jul 2025 21:05:50 +0000 Subject: [PATCH 6/7] comment out hash check --- server/homebrew.api.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 384031c77..c4feae16f 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -340,8 +340,10 @@ const api = { const brewFromClient = api.excludePropsFromUpdate(req.body); const brewFromServer = req.brew; const serverHash = md5(brewFromServer.text); + console.log({serverHash: serverHash}); + console.log({clientHash: brewFromClient.hash}); - if((brewFromServer?.version !== brewFromClient?.version) || (serverHash !== brewFromClient.hash)) { + if((brewFromServer?.version !== brewFromClient?.version)) { console.log(`Version mismatch on brew ${brewFromClient.editId}`); res.setHeader('Content-Type', 'application/json'); return res.status(409).send(JSON.stringify({ message: `The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` })); From 6414e73e7db62b40745fe6debcc92d18225cf993 Mon Sep 17 00:00:00 2001 From: Trevor Buckner Date: Tue, 8 Jul 2025 15:50:27 -0400 Subject: [PATCH 7/7] Cleanup and better handling of pre-save snapshot --- client/homebrew/pages/editPage/editPage.jsx | 60 ++++++++++----------- server/homebrew.api.js | 30 +++++------ 2 files changed, 43 insertions(+), 47 deletions(-) diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index dc2f0f2a5..c1448c497 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -200,13 +200,17 @@ const EditPage = createClass({ if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT); if(this.state.isSaving) return; + + if(immediate) { + this.debounceSave(); + this.debounceSave.flush(); + return; + } if(this.hasChanges()) this.debounceSave(); else this.debounceSave.cancel(); - - if(immediate) this.debounceSave.flush(); }, handleGoogleClick : function(){ @@ -252,33 +256,17 @@ const EditPage = createClass({ await updateHistory(this.state.brew).catch(console.error); await versionHistoryGarbageCollection().catch(console.error); - const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); - - const brew = { ...this.state.brew }; - - let jsonString = JSON.stringify(brew); - let bytes = new TextEncoder().encode(jsonString).length; - - console.log(`Before size: ${bytes} bytes (${(bytes / 1024).toFixed(2)} KB)`); + const preSaveSnapshot = { ...this.state.brew } + //Prepare content to send to server + const brew = { ...this.state.brew }; brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1; + brew.patches = makePatches(this.savedBrew.text, brew.text); + brew.hash = await md5(this.savedBrew.text); + brew.text = undefined; + brew.textBin = undefined; - brew.patches = makePatches(this.savedBrew.text, brew.text); - brew.hash = await md5(this.savedBrew.text); - brew.text = undefined; - brew.textBin = undefined; - console.log('Saving Brew', brew); - - jsonString = JSON.stringify(brew); - bytes = new TextEncoder().encode(jsonString).length; - - console.log(`After size: ${bytes} bytes (${(bytes / 1024).toFixed(2)} KB)`); - - jsonString = JSON.stringify(brew.patches); - bytes = new TextEncoder().encode(jsonString).length; - - console.log(`Patch size: ${bytes} bytes (${(bytes / 1024).toFixed(2)} KB)`); - + const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); const params = `${transfer ? `?${this.state.saveGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''}`; const res = await request .put(`/api/update/${brew.editId}${params}`) @@ -290,20 +278,28 @@ const EditPage = createClass({ if(!res) return; this.savedBrew = { - ...this.state.brew, + ...preSaveSnapshot, googleId : res.body.googleId ? res.body.googleId : null, editId : res.body.editId, shareId : res.body.shareId, version : res.body.version }; - history.replaceState(null, null, `/edit/${this.savedBrew.editId}`); - this.setState(()=>({ - brew : this.savedBrew, - unsavedChanges : false, + this.setState((prevState) => ({ + brew: { + ...prevState.brew, + googleId : res.body.googleId ? res.body.googleId : null, + editId : res.body.editId, + shareId : res.body.shareId, + version : res.body.version + }, isSaving : false, unsavedTime : new Date() - })); + }), ()=>{ + this.setState({ unsavedChanges : this.hasChanges() }); + }); + + history.replaceState(null, null, `/edit/${this.savedBrew.editId}`); }, renderGoogleDriveIcon : function(){ diff --git a/server/homebrew.api.js b/server/homebrew.api.js index c4feae16f..d0b1c43c8 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -339,30 +339,30 @@ const api = { // Initialize brew from request and body, destructure query params, and set the initial value for the after-save method const brewFromClient = api.excludePropsFromUpdate(req.body); const brewFromServer = req.brew; - const serverHash = md5(brewFromServer.text); - console.log({serverHash: serverHash}); - console.log({clientHash: brewFromClient.hash}); + splitTextStyleAndMetadata(brewFromServer); - if((brewFromServer?.version !== brewFromClient?.version)) { - console.log(`Version mismatch on brew ${brewFromClient.editId}`); + brewFromServer.hash = await md5(brewFromServer.text); + + if((brewFromServer?.version !== brewFromClient?.version) || (brewFromServer?.hash !== brewFromClient?.hash)) { + if(brewFromClient?.version !== brewFromClient?.version) + console.log(`Version mismatch on brew ${brewFromClient.editId}`); + if(brewFromServer?.hash !== brewFromClient?.hash) { + console.log(`Hash mismatch on brew ${brewFromClient.editId}`); + } res.setHeader('Content-Type', 'application/json'); return res.status(409).send(JSON.stringify({ message: `The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` })); } + + let brew = _.assign(brewFromServer, brewFromClient); + brew.title = brew.title.trim(); + brew.description = brew.description.trim() || ''; + brew.text = applyPatches(brewFromClient.patches, brewFromServer.text)[0]; + brew.text = api.mergeBrewText(brew); - console.log(`Brewfromserver: ${JSON.stringify(brewFromServer)}`); - splitTextStyleAndMetadata(brewFromServer); - brewFromClient.text = applyPatches(brewFromClient.patches, brewFromServer.text)[0]; - console.log(`Server Text: ${brewFromServer.text}`); - console.log(`Brew text: ${brewFromClient.text}`); - let brew = _.assign(brewFromServer, brewFromClient); const googleId = brew.googleId; const { saveToGoogle, removeFromGoogle } = req.query; let afterSave = async ()=>true; - brew.title = brew.title.trim(); - brew.description = brew.description.trim() || ''; - brew.text = api.mergeBrewText(brew); - if(brew.googleId && removeFromGoogle) { // If the google id exists and we're removing it from google, set afterSave to delete the google brew and mark the brew's google id as undefined afterSave = async ()=>{