0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-01 15:12:40 +00:00

Merge branch 'master' into opengraph_locale

This commit is contained in:
David Bolack
2025-07-16 10:33:27 -05:00
8 changed files with 2274 additions and 2417 deletions

View File

@@ -5,6 +5,15 @@ updates:
schedule: schedule:
interval: daily interval: daily
open-pull-requests-limit: 99 open-pull-requests-limit: 99
groups:
dev-dependencies:
dependency-type: "development"
patterns: ["*"]
update-types: ["patch", "minor"]
prod-dependencies:
dependency-type: "production"
patterns: ["*"]
update-types: ["patch", "minor"]
ignore: ignore:
- dependency-name: eslint - dependency-name: eslint
versions: versions:

View File

@@ -88,6 +88,22 @@ 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.2
{{taskList
##### calculuschild
* [x] Hotfix for saving issues - Please refresh your browser and report if problems continue
}}
### Wednesday 7/09/2025 - v3.19.1
{{taskList
##### calculuschild
* [x] Send diffs instead of full file on save - should help with timeout/disconnect errors
}}
\column
### Thursday 05/22/2025 - v3.19.0 ### Thursday 05/22/2025 - v3.19.0
{{taskList {{taskList

View File

@@ -3,6 +3,8 @@ require('./editPage.less');
const React = require('react'); const React = require('react');
const _ = require('lodash'); const _ = require('lodash');
const createClass = require('create-react-class'); 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'; import request from '../../utils/request-middleware.js';
const { Meta } = require('vitreum/headtags'); const { Meta } = require('vitreum/headtags');
@@ -47,7 +49,7 @@ const EditPage = createClass({
return { return {
brew : this.props.brew, brew : this.props.brew,
isSaving : false, isSaving : false,
isPending : false, unsavedChanges : false,
alertTrashedGoogleBrew : this.props.brew.trashed, alertTrashedGoogleBrew : this.props.brew.trashed,
alertLoginToTransfer : false, alertLoginToTransfer : false,
saveGoogle : this.props.brew.googleId ? true : false, saveGoogle : this.props.brew.googleId ? true : false,
@@ -85,7 +87,7 @@ const EditPage = createClass({
}); });
window.onbeforeunload = ()=>{ window.onbeforeunload = ()=>{
if(this.state.isSaving || this.state.isPending){ if(this.state.isSaving || this.state.unsavedChanges){
return 'You have unsaved changes!'; return 'You have unsaved changes!';
} }
}; };
@@ -104,9 +106,9 @@ const EditPage = createClass({
}, },
componentDidUpdate : function(){ componentDidUpdate : function(){
const hasChange = this.hasChanges(); const hasChange = this.hasChanges();
if(this.state.isPending != hasChange){ if(this.state.unsavedChanges != hasChange){
this.setState({ this.setState({
isPending : hasChange unsavedChanges : hasChange
}); });
} }
}, },
@@ -156,9 +158,9 @@ const EditPage = createClass({
if(htmlErrors.length) htmlErrors = Markdown.validate(snippet); if(htmlErrors.length) htmlErrors = Markdown.validate(snippet);
this.setState((prevState)=>({ this.setState((prevState)=>({
brew : { ...prevState.brew, snippets: snippet }, brew : { ...prevState.brew, snippets: snippet },
isPending : true, unsavedChanges : true,
htmlErrors : htmlErrors, htmlErrors : htmlErrors,
}), ()=>{if(this.state.autoSave) this.trySave();}); }), ()=>{if(this.state.autoSave) this.trySave();});
}, },
@@ -196,12 +198,19 @@ const EditPage = createClass({
trySave : function(immediate=false){ trySave : function(immediate=false){
if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT); if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT);
if(this.hasChanges()){ if(this.state.isSaving)
return;
if(immediate) {
this.debounceSave(); this.debounceSave();
} else { this.debounceSave.flush();
this.debounceSave.cancel(); return;
} }
if(immediate) this.debounceSave.flush();
if(this.hasChanges())
this.debounceSave();
else
this.debounceSave.cancel();
}, },
handleGoogleClick : function(){ handleGoogleClick : function(){
@@ -215,8 +224,7 @@ const EditPage = createClass({
confirmGoogleTransfer : !prevState.confirmGoogleTransfer confirmGoogleTransfer : !prevState.confirmGoogleTransfer
})); }));
this.setState({ this.setState({
error : null, error : null
isSaving : false
}); });
}, },
@@ -232,9 +240,8 @@ const EditPage = createClass({
toggleGoogleStorage : function(){ toggleGoogleStorage : function(){
this.setState((prevState)=>({ this.setState((prevState)=>({
saveGoogle : !prevState.saveGoogle, saveGoogle : !prevState.saveGoogle,
isSaving : false,
error : null error : null
}), ()=>this.save()); }), ()=>this.trySave(true));
}, },
save : async function(){ save : async function(){
@@ -249,11 +256,19 @@ 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
const brew = { ...this.state.brew };
brew.text = brew.text.normalize();
this.savedBrew.text = this.savedBrew.text.normalize();
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.textBin = undefined;
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
const brew = this.state.brew;
brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
const params = `${transfer ? `?${this.state.saveGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''}`; const params = `${transfer ? `?${this.state.saveGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''}`;
const res = await request const res = await request
.put(`/api/update/${brew.editId}${params}`) .put(`/api/update/${brew.editId}${params}`)
@@ -265,20 +280,28 @@ const EditPage = createClass({
if(!res) return; if(!res) return;
this.savedBrew = { this.savedBrew = {
...this.state.brew, ...preSaveSnapshot,
googleId : res.body.googleId ? res.body.googleId : null, googleId : res.body.googleId ? res.body.googleId : null,
editId : res.body.editId, editId : res.body.editId,
shareId : res.body.shareId, shareId : res.body.shareId,
version : res.body.version version : res.body.version
}; };
history.replaceState(null, null, `/edit/${this.savedBrew.editId}`);
this.setState(()=>({ this.setState((prevState) => ({
brew : this.savedBrew, brew: {
isPending : false, ...prevState.brew,
isSaving : false, googleId : res.body.googleId ? res.body.googleId : null,
unsavedTime : new Date() 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(){ renderGoogleDriveIcon : function(){
@@ -336,7 +359,7 @@ const EditPage = createClass({
} }
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING // #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
if(this.state.isPending && this.state.autoSaveWarning){ if(this.state.unsavedChanges && this.state.autoSaveWarning){
this.setAutosaveWarning(); this.setAutosaveWarning();
const elapsedTime = Math.round((new Date() - this.state.unsavedTime) / 1000 / 60); const elapsedTime = Math.round((new Date() - this.state.unsavedTime) / 1000 / 60);
const text = elapsedTime == 0 ? 'Autosave is OFF.' : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`; const text = elapsedTime == 0 ? 'Autosave is OFF.' : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
@@ -351,7 +374,7 @@ const EditPage = createClass({
// #3 - Unsaved changes exist, click to save, show SAVE NOW // #3 - Unsaved changes exist, click to save, show SAVE NOW
// Use trySave(true) instead of save() to use debounced save function // Use trySave(true) instead of save() to use debounced save function
if(this.state.isPending){ if(this.state.unsavedChanges){
return <Nav.item className='save' onClick={()=>this.trySave(true)} color='blue' icon='fas fa-save'>Save Now</Nav.item>; return <Nav.item className='save' onClick={()=>this.trySave(true)} color='blue' icon='fas fa-save'>Save Now</Nav.item>;
} }
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED // #4 - No unsaved changes, autosave is ON, show AUTO-SAVED

View File

@@ -148,7 +148,6 @@ const NewPage = createClass({
this.setState((prevState)=>({ this.setState((prevState)=>({
brew : { ...prevState.brew, snippets: snippet }, brew : { ...prevState.brew, snippets: snippet },
isPending : true,
htmlErrors : htmlErrors, htmlErrors : htmlErrors,
}), ()=>{if(this.state.autoSave) this.trySave();}); }), ()=>{if(this.state.autoSave) this.trySave();});
}, },

4497
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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.0", "version": "3.19.2",
"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": 70, "statements": 69,
"branches": 50, "branches": 50,
"functions": 65, "functions": 65,
"lines": 70 "lines": 70
@@ -84,16 +84,17 @@
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.27.1", "@babel/core": "^7.27.1",
"@babel/plugin-transform-runtime": "^7.27.1", "@babel/plugin-transform-runtime": "^7.28.0",
"@babel/preset-env": "^7.27.2", "@babel/preset-env": "^7.28.0",
"@babel/preset-react": "^7.27.1", "@babel/preset-react": "^7.27.1",
"@babel/runtime": "^7.27.1", "@babel/runtime": "^7.27.6",
"@googleapis/drive": "^12.1.0", "@googleapis/drive": "^13.0.1",
"@sanity/diff-match-patch": "^3.2.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"codemirror": "^5.65.6", "codemirror": "^5.65.6",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"core-js": "^3.42.0", "core-js": "^3.43.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"create-react-class": "^15.7.0", "create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3", "dedent-tabs": "^0.10.3",
@@ -102,6 +103,7 @@
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
"express-static-gzip": "3.0.0", "express-static-gzip": "3.0.0",
"fs-extra": "11.3.0", "fs-extra": "11.3.0",
"hash-wasm": "^4.12.0",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jwt-simple": "^0.5.6", "jwt-simple": "^0.5.6",
@@ -110,40 +112,40 @@
"marked": "15.0.12", "marked": "15.0.12",
"marked-alignment-paragraphs": "^1.0.0", "marked-alignment-paragraphs": "^1.0.0",
"marked-definition-lists": "^1.0.1", "marked-definition-lists": "^1.0.1",
"marked-emoji": "^2.0.0", "marked-emoji": "^2.0.1",
"marked-extended-tables": "^2.0.1", "marked-extended-tables": "^2.0.1",
"marked-gfm-heading-id": "^4.0.1", "marked-gfm-heading-id": "^4.1.2",
"marked-nonbreaking-spaces": "^1.0.1", "marked-nonbreaking-spaces": "^1.0.1",
"marked-smartypants-lite": "^1.0.3", "marked-smartypants-lite": "^1.0.3",
"marked-subsuper-text": "^1.0.3", "marked-subsuper-text": "^1.0.3",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^8.15.0", "mongoose": "^8.16.1",
"nanoid": "5.1.5", "nanoid": "5.1.5",
"nconf": "^0.13.0", "nconf": "^0.13.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-frame-component": "^4.1.3", "react-frame-component": "^4.1.3",
"react-router": "^7.6.0", "react-router": "^7.6.3",
"romans": "^3.0.0", "romans": "^3.1.0",
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"superagent": "^10.2.1", "superagent": "^10.2.1",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git", "vitreum": "git+https://git@github.com/calculuschild/vitreum.git",
"written-number": "^0.11.1" "written-number": "^0.11.1"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/stylelint-plugin": "^3.1.2", "@stylistic/stylelint-plugin": "^3.1.3",
"babel-plugin-transform-import-meta": "^2.3.2", "babel-plugin-transform-import-meta": "^2.3.3",
"eslint": "^9.27.0", "eslint": "^9.30.1",
"eslint-plugin-jest": "^28.11.0", "eslint-plugin-jest": "^29.0.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^16.1.0", "globals": "^16.3.0",
"jest": "^29.7.0", "jest": "^30.0.4",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"jsdom-global": "^3.0.2", "jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"stylelint": "^16.19.1", "stylelint": "^16.21.1",
"stylelint-config-recess-order": "^6.0.0", "stylelint-config-recess-order": "^7.1.0",
"stylelint-config-recommended": "^16.0.0", "stylelint-config-recommended": "^16.0.0",
"supertest": "^7.1.1" "supertest": "^7.1.1"
} }

View File

@@ -27,6 +27,7 @@
"codemirror/addon/selection/active-line.js", "codemirror/addon/selection/active-line.js",
"codemirror/addon/hint/show-hint.js", "codemirror/addon/hint/show-hint.js",
"moment", "moment",
"superagent" "superagent",
"@sanity/diff-match-patch"
] ]
} }

View File

@@ -8,6 +8,8 @@ import Markdown from '../shared/naturalcrit/markdown.js';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import asyncHandler from 'express-async-handler'; import asyncHandler from 'express-async-handler';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import {makePatches, applyPatches, stringifyPatches, parsePatch} from '@sanity/diff-match-patch';
import { md5 } from 'hash-wasm';
import { splitTextStyleAndMetadata, import { splitTextStyleAndMetadata,
brewSnippetsToJSON } from '../shared/helpers.js'; brewSnippetsToJSON } from '../shared/helpers.js';
import checkClientVersion from './middleware/check-client-version.js'; import checkClientVersion from './middleware/check-client-version.js';
@@ -337,21 +339,41 @@ const api = {
// Initialize brew from request and body, destructure query params, and set the initial value for the after-save method // 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 brewFromClient = api.excludePropsFromUpdate(req.body);
const brewFromServer = req.brew; const brewFromServer = req.brew;
if(brewFromServer.version && brewFromClient.version && brewFromServer.version > brewFromClient.version) { splitTextStyleAndMetadata(brewFromServer);
console.log(`Version mismatch on brew ${brewFromClient.editId}`);
brewFromServer.text = brewFromServer.text.normalize();
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'); 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.` }));
} }
let brew = _.assign(brewFromServer, brewFromClient); 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; const googleId = brew.googleId;
const { saveToGoogle, removeFromGoogle } = req.query; const { saveToGoogle, removeFromGoogle } = req.query;
let afterSave = async ()=>true; let afterSave = async ()=>true;
brew.title = brew.title.trim();
brew.description = brew.description.trim() || '';
brew.text = api.mergeBrewText(brew);
if(brew.googleId && removeFromGoogle) { 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 // 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 ()=>{ afterSave = async ()=>{
@@ -484,8 +506,8 @@ const api = {
}; };
router.post('/api', checkClientVersion, asyncHandler(api.newBrew)); router.post('/api', checkClientVersion, asyncHandler(api.newBrew));
router.put('/api/: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', true)), 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.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew)); router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle)); router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));