diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 92a9c5349..c1448c497 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -3,6 +3,8 @@ 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 { md5 } from 'hash-wasm'; import request from '../../utils/request-middleware.js'; const { Meta } = require('vitreum/headtags'); @@ -196,12 +198,19 @@ const EditPage = createClass({ trySave : function(immediate=false){ if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT); - if(this.hasChanges()){ + if(this.state.isSaving) + return; + + if(immediate) { this.debounceSave(); - } else { - this.debounceSave.cancel(); + this.debounceSave.flush(); + return; } - if(immediate) this.debounceSave.flush(); + + if(this.hasChanges()) + this.debounceSave(); + else + this.debounceSave.cancel(); }, handleGoogleClick : function(){ @@ -215,8 +224,7 @@ const EditPage = createClass({ confirmGoogleTransfer : !prevState.confirmGoogleTransfer })); this.setState({ - error : null, - isSaving : false + error : null }); }, @@ -232,9 +240,8 @@ const EditPage = createClass({ toggleGoogleStorage : function(){ this.setState((prevState)=>({ saveGoogle : !prevState.saveGoogle, - isSaving : false, error : null - }), ()=>this.save()); + }), ()=>this.trySave(true)); }, save : async function(){ @@ -249,11 +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 preSaveSnapshot = { ...this.state.brew } - const brew = 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; + 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}`) @@ -265,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/package-lock.json b/package-lock.json index 8eee47b08..3915194cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@babel/preset-react": "^7.27.1", "@babel/runtime": "^7.27.6", "@googleapis/drive": "^13.0.1", + "@sanity/diff-match-patch": "^3.2.0", "body-parser": "^2.2.0", "classnames": "^2.5.1", "codemirror": "^5.65.6", @@ -29,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", @@ -2895,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", @@ -7440,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 c8c0dec96..3099af3f4 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@babel/preset-react": "^7.27.1", "@babel/runtime": "^7.27.6", "@googleapis/drive": "^13.0.1", + "@sanity/diff-match-patch": "^3.2.0", "body-parser": "^2.2.0", "classnames": "^2.5.1", "codemirror": "^5.65.6", @@ -102,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", 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..d0b1c43c8 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -8,6 +8,8 @@ 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 { md5 } from 'hash-wasm'; import { splitTextStyleAndMetadata, brewSnippetsToJSON } from '../shared/helpers.js'; import checkClientVersion from './middleware/check-client-version.js'; @@ -337,21 +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; - 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.` })); - } + splitTextStyleAndMetadata(brewFromServer); + + 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); - 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 ()=>{ @@ -484,8 +495,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));