diff --git a/changelog.md b/changelog.md index 3f85850fc..0a108b9f2 100644 --- a/changelog.md +++ b/changelog.md @@ -88,6 +88,14 @@ pre { ## changelog For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). +### Wednesday 7/09/2025 - v3.19.3 + +{{taskList +##### calculuschild +* [x] Restoring original saving behavior; will continue investigating why save was failing for some users in background +}} + + ### Wednesday 7/09/2025 - v3.19.2 {{taskList diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 7e6c03473..c81f440be 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -190,8 +190,9 @@ const EditPage = createClass({ this.setState((prevState)=>({ brew : { ...prevState.brew, - style : newData.style, - text : newData.text + style : newData.style, + text : newData.text, + snippets : newData.snippets } })); }, @@ -247,6 +248,9 @@ const EditPage = createClass({ save : async function(){ if(this.debounceSave && this.debounceSave.cancel) this.debounceSave.cancel(); + const brewState = this.state.brew; // freeze the current state + const preSaveSnapshot = { ...brewState }; + this.setState((prevState)=>({ isSaving : true, error : null, @@ -256,16 +260,14 @@ const EditPage = createClass({ await updateHistory(this.state.brew).catch(console.error); await versionHistoryGarbageCollection().catch(console.error); - const preSaveSnapshot = { ...this.state.brew }; - //Prepare content to send to server - const brew = { ...this.state.brew }; - brew.text = brew.text.normalize(); - this.savedBrew.text = this.savedBrew.text.normalize(); + const brew = { ...brewState }; + brew.text = brew.text.normalize('NFC'); + this.savedBrew.text = this.savedBrew.text.normalize('NFC'); brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1; brew.patches = stringifyPatches(makePatches(this.savedBrew.text, brew.text)); brew.hash = await md5(this.savedBrew.text); - brew.text = undefined; + //brew.text = undefined; - Temporary parallel path brew.textBin = undefined; const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); @@ -295,8 +297,8 @@ const EditPage = createClass({ shareId : res.body.shareId, version : res.body.version }, - isSaving : false, - unsavedTime : new Date() + isSaving : false, + unsavedTime : new Date() }), ()=>{ this.setState({ unsavedChanges : this.hasChanges() }); }); diff --git a/client/homebrew/utils/versionHistory.js b/client/homebrew/utils/versionHistory.js index ec3bd74e1..f3d6aa97b 100644 --- a/client/homebrew/utils/versionHistory.js +++ b/client/homebrew/utils/versionHistory.js @@ -42,6 +42,7 @@ function parseBrewForStorage(brew, slot = 0) { title : brew.title, text : brew.text, style : brew.style, + snippets : brew.snippets, version : brew.version, shareId : brew.shareId, savedAt : brew?.savedAt || new Date(), diff --git a/package-lock.json b/package-lock.json index 8ed54390e..8fa722ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "homebrewery", - "version": "3.19.2", + "version": "3.19.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "homebrewery", - "version": "3.19.2", + "version": "3.19.3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -15,6 +15,7 @@ "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/runtime": "^7.27.6", + "@dmsnell/diff-match-patch": "^1.1.0", "@googleapis/drive": "^13.0.1", "@sanity/diff-match-patch": "^3.2.0", "body-parser": "^2.2.0", @@ -1888,6 +1889,12 @@ "@csstools/css-tokenizer": "^3.0.1" } }, + "node_modules/@dmsnell/diff-match-patch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@dmsnell/diff-match-patch/-/diff-match-patch-1.1.0.tgz", + "integrity": "sha512-yejLPmM5pjsGvxS9gXablUSbInW7H976c/FJ4iQxWIm7/38xBySRemTPDe34lhg1gVLbJntX0+sH0jYfU+PN9A==", + "license": "Apache-2.0" + }, "node_modules/@dual-bundle/import-meta-resolve": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", diff --git a/package.json b/package.json index bc23a434e..71e892058 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "homebrewery", "description": "Create authentic looking D&D homebrews using only markdown", - "version": "3.19.2", + "version": "3.19.3", "type": "module", "engines": { "npm": "^10.8.x", @@ -72,7 +72,7 @@ "lines": 50 }, "server/homebrew.api.js": { - "statements": 69, + "statements": 60, "branches": 50, "functions": 65, "lines": 70 @@ -88,6 +88,7 @@ "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/runtime": "^7.27.6", + "@dmsnell/diff-match-patch": "^1.1.0", "@googleapis/drive": "^13.0.1", "@sanity/diff-match-patch": "^3.2.0", "body-parser": "^2.2.0", diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 842f8a9e6..bd8764b51 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -11,7 +11,7 @@ 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'; + brewSnippetsToJSON, debugTextMismatch } from '../shared/helpers.js'; import checkClientVersion from './middleware/check-client-version.js'; @@ -349,34 +349,45 @@ const api = { const brewFromClient = api.excludePropsFromUpdate(req.body); const brewFromServer = req.brew; splitTextStyleAndMetadata(brewFromServer); - - brewFromServer.text = brewFromServer.text.normalize(); + + 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 version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` })); + } + + brewFromServer.text = brewFromServer.text.normalize('NFC'); 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}`); - } + if(brewFromServer?.hash !== brewFromClient?.hash) { + console.log(`Hash mismatch on brew ${brewFromClient.editId}`); + //debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${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.` })); } + try { + const patches = parsePatch(brewFromClient.patches); + // Patch to a throwaway variable while parallelizing - we're more concerned with error/no error. + const patchedResult = applyPatches(patches, brewFromServer.text, { allowExceedingIndices: true })[0]; + if(patchedResult != brewFromClient.text) + throw("Patches did not apply cleanly, text mismatch detected"); + // brew.text = applyPatches(patches, brewFromServer.text)[0]; + } catch (err) { + //debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`); + console.error('Failed to apply patches:', { + patches : brewFromClient.patches, + brewId : brewFromClient.editId || 'unknown', + error : err + }); + // While running in parallel, don't throw the error upstream. + // throw err; // rethrow to preserve the 500 behavior + } + let brew = _.assign(brewFromServer, brewFromClient); brew.title = brew.title.trim(); brew.description = brew.description.trim() || ''; - try { - const patches = parsePatch(brewFromClient.patches); - brew.text = applyPatches(patches, brewFromServer.text)[0]; - } catch (err) { - console.error('Failed to apply patches:', { - patches: brewFromClient.patches, - brewId: brew.editId || 'unknown' - }); - throw err; // rethrow to preserve the 500 behavior - } - brew.text = api.mergeBrewText(brew); const googleId = brew.googleId; diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js index 1de46b455..18b6fd362 100644 --- a/server/homebrew.api.spec.js +++ b/server/homebrew.api.spec.js @@ -1085,4 +1085,83 @@ brew`); expect(testBrew.tags).toEqual(['tag a']); }); }); + + describe('updateBrew', ()=>{ + it('should return error on version mismatch', async ()=>{ + const brewFromClient = { version: 1 }; + const brewFromServer = { version: 1000 }; + + const req = { + brew : brewFromServer, + body : brewFromClient + }; + + await api.updateBrew(req, res); + + expect(res.status).toHaveBeenCalledWith(409); + expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}'); + }); + + it('should return error on hash mismatch', async ()=>{ + const brewFromClient = { version: 1, hash: '1234' }; + const brewFromServer = { version: 1, text: 'test' }; + + const req = { + brew : brewFromServer, + body : brewFromClient + }; + + await api.updateBrew(req, res); + + expect(req.brew.hash).toBe('098f6bcd4621d373cade4e832627b4f6'); + expect(res.status).toHaveBeenCalledWith(409); + expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}'); + }); + + // Commenting this one out for now, since we are no longer throwing this error while we monitor + // it('should return error on applying patches', async ()=>{ + // const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: 'not a valid patch string' }; + // const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' }; + + // const req = { + // brew : brewFromServer, + // body : brewFromClient, + // }; + + // let err; + // try { + // await api.updateBrew(req, res); + // } catch (e) { + // err = e; + // } + + // expect(err).toEqual(Error('Invalid patch string: not a valid patch string')); + // }); + + it('should save brew, no ID', async ()=>{ + const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: '' }; + const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' }; + + model.save = jest.fn((brew)=>{return brew;}); + + const req = { + brew : brewFromServer, + body : brewFromClient, + query : { saveToGoogle: false, removeFromGoogle: false } + }; + + await api.updateBrew(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith( + expect.objectContaining({ + _id : '1', + description : 'Test Description', + hash : '098f6bcd4621d373cade4e832627b4f6', + title : 'Test Title', + version : 2 + }) + ); + }); + }); }); diff --git a/shared/helpers.js b/shared/helpers.js index 0ca681dfb..e09b0bdc4 100644 --- a/shared/helpers.js +++ b/shared/helpers.js @@ -139,9 +139,45 @@ const fetchThemeBundle = async (obj, renderer, theme)=>{ })); }; +const debugTextMismatch = (clientTextRaw, serverTextRaw, label) => { + const clientText = clientTextRaw?.normalize('NFC') || ''; + const serverText = serverTextRaw?.normalize('NFC') || ''; + + const clientBuffer = Buffer.from(clientText, 'utf8'); + const serverBuffer = Buffer.from(serverText, 'utf8'); + + if (clientBuffer.equals(serverBuffer)) { + console.log(`✅ ${label} text matches byte-for-byte.`); + return; + } + + console.warn(`❗${label} text mismatch detected.`); + console.log(`Client length: ${clientBuffer.length}`); + console.log(`Server length: ${serverBuffer.length}`); + + // Byte-level diff + for (let i = 0; i < Math.min(clientBuffer.length, serverBuffer.length); i++) { + if (clientBuffer[i] !== serverBuffer[i]) { + console.log(`Byte mismatch at offset ${i}: client=0x${clientBuffer[i].toString(16)} server=0x${serverBuffer[i].toString(16)}`); + break; + } + } + + // Char-level diff + for (let i = 0; i < Math.min(clientText.length, serverText.length); i++) { + if (clientText[i] !== serverText[i]) { + console.log(`Char mismatch at index ${i}:`); + console.log(` Client: '${clientText[i]}' (U+${clientText.charCodeAt(i).toString(16).toUpperCase()})`); + console.log(` Server: '${serverText[i]}' (U+${serverText.charCodeAt(i).toString(16).toUpperCase()})`); + break; + } + } +} + export { splitTextStyleAndMetadata, printCurrentBrew, fetchThemeBundle, - brewSnippetsToJSON + brewSnippetsToJSON, + debugTextMismatch };