mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-01-14 17:12:40 +00:00
Merge branch 'master' into experimentalIDValidations
This commit is contained in:
@@ -88,6 +88,14 @@ pre {
|
|||||||
## changelog
|
## changelog
|
||||||
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
|
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
|
### Wednesday 7/09/2025 - v3.19.2
|
||||||
|
|
||||||
{{taskList
|
{{taskList
|
||||||
|
|||||||
@@ -190,8 +190,9 @@ const EditPage = createClass({
|
|||||||
this.setState((prevState)=>({
|
this.setState((prevState)=>({
|
||||||
brew : {
|
brew : {
|
||||||
...prevState.brew,
|
...prevState.brew,
|
||||||
style : newData.style,
|
style : newData.style,
|
||||||
text : newData.text
|
text : newData.text,
|
||||||
|
snippets : newData.snippets
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@@ -247,6 +248,9 @@ const EditPage = createClass({
|
|||||||
save : async function(){
|
save : async function(){
|
||||||
if(this.debounceSave && this.debounceSave.cancel) this.debounceSave.cancel();
|
if(this.debounceSave && this.debounceSave.cancel) this.debounceSave.cancel();
|
||||||
|
|
||||||
|
const brewState = this.state.brew; // freeze the current state
|
||||||
|
const preSaveSnapshot = { ...brewState };
|
||||||
|
|
||||||
this.setState((prevState)=>({
|
this.setState((prevState)=>({
|
||||||
isSaving : true,
|
isSaving : true,
|
||||||
error : null,
|
error : null,
|
||||||
@@ -256,16 +260,14 @@ const EditPage = createClass({
|
|||||||
await updateHistory(this.state.brew).catch(console.error);
|
await updateHistory(this.state.brew).catch(console.error);
|
||||||
await versionHistoryGarbageCollection().catch(console.error);
|
await versionHistoryGarbageCollection().catch(console.error);
|
||||||
|
|
||||||
const preSaveSnapshot = { ...this.state.brew };
|
|
||||||
|
|
||||||
//Prepare content to send to server
|
//Prepare content to send to server
|
||||||
const brew = { ...this.state.brew };
|
const brew = { ...brewState };
|
||||||
brew.text = brew.text.normalize();
|
brew.text = brew.text.normalize('NFC');
|
||||||
this.savedBrew.text = this.savedBrew.text.normalize();
|
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.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.patches = stringifyPatches(makePatches(this.savedBrew.text, brew.text));
|
||||||
brew.hash = await md5(this.savedBrew.text);
|
brew.hash = await md5(this.savedBrew.text);
|
||||||
brew.text = undefined;
|
//brew.text = undefined; - Temporary parallel path
|
||||||
brew.textBin = undefined;
|
brew.textBin = undefined;
|
||||||
|
|
||||||
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
|
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
|
||||||
@@ -295,8 +297,8 @@ const EditPage = createClass({
|
|||||||
shareId : res.body.shareId,
|
shareId : res.body.shareId,
|
||||||
version : res.body.version
|
version : res.body.version
|
||||||
},
|
},
|
||||||
isSaving : false,
|
isSaving : false,
|
||||||
unsavedTime : new Date()
|
unsavedTime : new Date()
|
||||||
}), ()=>{
|
}), ()=>{
|
||||||
this.setState({ unsavedChanges : this.hasChanges() });
|
this.setState({ unsavedChanges : this.hasChanges() });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function parseBrewForStorage(brew, slot = 0) {
|
|||||||
title : brew.title,
|
title : brew.title,
|
||||||
text : brew.text,
|
text : brew.text,
|
||||||
style : brew.style,
|
style : brew.style,
|
||||||
|
snippets : brew.snippets,
|
||||||
version : brew.version,
|
version : brew.version,
|
||||||
shareId : brew.shareId,
|
shareId : brew.shareId,
|
||||||
savedAt : brew?.savedAt || new Date(),
|
savedAt : brew?.savedAt || new Date(),
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "homebrewery",
|
"name": "homebrewery",
|
||||||
"version": "3.19.2",
|
"version": "3.19.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "homebrewery",
|
"name": "homebrewery",
|
||||||
"version": "3.19.2",
|
"version": "3.19.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"@babel/preset-env": "^7.28.0",
|
"@babel/preset-env": "^7.28.0",
|
||||||
"@babel/preset-react": "^7.27.1",
|
"@babel/preset-react": "^7.27.1",
|
||||||
"@babel/runtime": "^7.27.6",
|
"@babel/runtime": "^7.27.6",
|
||||||
|
"@dmsnell/diff-match-patch": "^1.1.0",
|
||||||
"@googleapis/drive": "^13.0.1",
|
"@googleapis/drive": "^13.0.1",
|
||||||
"@sanity/diff-match-patch": "^3.2.0",
|
"@sanity/diff-match-patch": "^3.2.0",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
@@ -1888,6 +1889,12 @@
|
|||||||
"@csstools/css-tokenizer": "^3.0.1"
|
"@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": {
|
"node_modules/@dual-bundle/import-meta-resolve": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "homebrewery",
|
"name": "homebrewery",
|
||||||
"description": "Create authentic looking D&D homebrews using only markdown",
|
"description": "Create authentic looking D&D homebrews using only markdown",
|
||||||
"version": "3.19.2",
|
"version": "3.19.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"npm": "^10.8.x",
|
"npm": "^10.8.x",
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"lines": 50
|
"lines": 50
|
||||||
},
|
},
|
||||||
"server/homebrew.api.js": {
|
"server/homebrew.api.js": {
|
||||||
"statements": 69,
|
"statements": 60,
|
||||||
"branches": 50,
|
"branches": 50,
|
||||||
"functions": 65,
|
"functions": 65,
|
||||||
"lines": 70
|
"lines": 70
|
||||||
@@ -88,6 +88,7 @@
|
|||||||
"@babel/preset-env": "^7.28.0",
|
"@babel/preset-env": "^7.28.0",
|
||||||
"@babel/preset-react": "^7.27.1",
|
"@babel/preset-react": "^7.27.1",
|
||||||
"@babel/runtime": "^7.27.6",
|
"@babel/runtime": "^7.27.6",
|
||||||
|
"@dmsnell/diff-match-patch": "^1.1.0",
|
||||||
"@googleapis/drive": "^13.0.1",
|
"@googleapis/drive": "^13.0.1",
|
||||||
"@sanity/diff-match-patch": "^3.2.0",
|
"@sanity/diff-match-patch": "^3.2.0",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { nanoid } from 'nanoid';
|
|||||||
import {makePatches, applyPatches, stringifyPatches, parsePatch} from '@sanity/diff-match-patch';
|
import {makePatches, applyPatches, stringifyPatches, parsePatch} from '@sanity/diff-match-patch';
|
||||||
import { md5 } from 'hash-wasm';
|
import { md5 } from 'hash-wasm';
|
||||||
import { splitTextStyleAndMetadata,
|
import { splitTextStyleAndMetadata,
|
||||||
brewSnippetsToJSON } from '../shared/helpers.js';
|
brewSnippetsToJSON, debugTextMismatch } from '../shared/helpers.js';
|
||||||
import checkClientVersion from './middleware/check-client-version.js';
|
import checkClientVersion from './middleware/check-client-version.js';
|
||||||
|
|
||||||
|
|
||||||
@@ -349,34 +349,45 @@ const api = {
|
|||||||
const brewFromClient = api.excludePropsFromUpdate(req.body);
|
const brewFromClient = api.excludePropsFromUpdate(req.body);
|
||||||
const brewFromServer = req.brew;
|
const brewFromServer = req.brew;
|
||||||
splitTextStyleAndMetadata(brewFromServer);
|
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);
|
brewFromServer.hash = await md5(brewFromServer.text);
|
||||||
|
|
||||||
if((brewFromServer?.version !== brewFromClient?.version) || (brewFromServer?.hash !== brewFromClient?.hash)) {
|
if(brewFromServer?.hash !== brewFromClient?.hash) {
|
||||||
if(brewFromClient?.version !== brewFromClient?.version)
|
console.log(`Hash mismatch on brew ${brewFromClient.editId}`);
|
||||||
console.log(`Version mismatch on brew ${brewFromClient.editId}`);
|
//debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`);
|
||||||
if(brewFromServer?.hash !== brewFromClient?.hash) {
|
|
||||||
console.log(`Hash mismatch on brew ${brewFromClient.editId}`);
|
|
||||||
}
|
|
||||||
res.setHeader('Content-Type', 'application/json');
|
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.` }));
|
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);
|
let brew = _.assign(brewFromServer, brewFromClient);
|
||||||
brew.title = brew.title.trim();
|
brew.title = brew.title.trim();
|
||||||
brew.description = brew.description.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);
|
brew.text = api.mergeBrewText(brew);
|
||||||
|
|
||||||
const googleId = brew.googleId;
|
const googleId = brew.googleId;
|
||||||
|
|||||||
@@ -1085,4 +1085,83 @@ brew`);
|
|||||||
expect(testBrew.tags).toEqual(['tag a']);
|
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
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
export {
|
||||||
splitTextStyleAndMetadata,
|
splitTextStyleAndMetadata,
|
||||||
printCurrentBrew,
|
printCurrentBrew,
|
||||||
fetchThemeBundle,
|
fetchThemeBundle,
|
||||||
brewSnippetsToJSON
|
brewSnippetsToJSON,
|
||||||
|
debugTextMismatch
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user