Merge branch 'master' into fix-codemirror

This commit is contained in:
Trevor Buckner
2026-10-22 15:09:15 -04:00
committed by GitHub
3 changed files with 159 additions and 201 deletions
+78 -135
View File
@@ -2,7 +2,6 @@
import _ from 'lodash'; import _ from 'lodash';
import { model as HomebrewModel } from './homebrew.model.js'; import { model as HomebrewModel } from './homebrew.model.js';
import express from 'express'; import express from 'express';
import zlib from 'zlib';
import GoogleActions from './googleActions.js'; import GoogleActions from './googleActions.js';
import { hbfm } from 'marked-hbfm'; import { hbfm } from 'marked-hbfm';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
@@ -27,17 +26,8 @@ const isStaticTheme = (renderer, themeName)=>{
return Themes[renderer]?.[themeName] !== undefined; return Themes[renderer]?.[themeName] !== undefined;
}; };
// const getTopBrews = (cb) => {
// HomebrewModel.find().sort({ views: -1 }).limit(5).exec(function(err, brews) {
// cb(brews);
// });
// };
const migrateSystemsToTags = (brew)=>{ const migrateSystemsToTags = (brew)=>{
if(!('systems' in brew)) return brew; if(!('systems' in brew) || !Array.isArray(brew.systems) || brew.systems.length === 0) {
if(!Array.isArray(brew.systems) || brew.systems.length === 0) {
brew.systems = undefined; brew.systems = undefined;
return brew; return brew;
} }
@@ -108,19 +98,17 @@ const api = {
const brews = await HomebrewModel.getByUser(username, true, fields, { tags: { $in: ['meta:theme', 'meta:Theme'] } }); const brews = await HomebrewModel.getByUser(username, true, fields, { tags: { $in: ['meta:theme', 'meta:Theme'] } });
if(brews) { for (const brew of brews) {
for (const brew of brews) { userThemes[brew.renderer] ??= {};
userThemes[brew.renderer] ??= {}; userThemes[brew.renderer][brew.shareId] = {
userThemes[brew.renderer][brew.shareId] = { name : brew.title,
name : brew.title, renderer : brew.renderer,
renderer : brew.renderer, baseTheme : brew.theme,
baseTheme : brew.theme, baseSnippets : false,
baseSnippets : false, author : brew.authors[0],
author : brew.authors[0], path : brew.shareId,
path : brew.shareId, thumbnail : brew.thumbnail || '/assets/naturalCritLogoWhite.svg'
thumbnail : brew.thumbnail || '/assets/naturalCritLogoWhite.svg' };
};
}
} }
return userThemes; return userThemes;
@@ -189,18 +177,14 @@ const api = {
stub.renderer = stub.renderer || undefined; // Clear empty strings stub.renderer = stub.renderer || undefined; // Clear empty strings
stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields
req.brew = migrateSystemsToTags(stub);
const fixedStub = migrateSystemsToTags(stub);
req.brew = fixedStub;
splitTextStyleAndMetadata(req.brew); splitTextStyleAndMetadata(req.brew);
next(); next();
}; };
}, },
getCSS : async (req, res)=>{ getCSS : async (req, res)=>{
const { brew } = req; const { brew } = req;
if(!brew) return res.status(404).send(''); if(!brew?.style) return res.status(404).send('');
if(!brew.style) return res.status(404).send('');
res.set({ res.set({
'Cache-Control' : 'no-cache', 'Cache-Control' : 'no-cache',
@@ -216,19 +200,15 @@ const api = {
`\`\`\`\n\n` + `\`\`\`\n\n` +
`${text}`; `${text}`;
} }
const metadata = _.pick(brew, ['title', 'description', 'tags', 'renderer', 'theme']); const metadata = _.pick(brew, ['title', 'description', 'tags', 'renderer', 'theme', 'columns', 'columnGutter', 'license', 'legalAuthors']);
const snippetsArray = brewSnippetsToJSON('brew_snippets', brew.snippets, null, false).snippets; const snippetsArray = brewSnippetsToJSON('brew_snippets', brew.snippets, null, false).snippets;
metadata.snippets = snippetsArray.length > 0 ? snippetsArray : undefined; metadata.snippets = snippetsArray.length > 0 ? snippetsArray : undefined;
metadata.bleedSize = { top: brew?.bleedSize?.top, bottom: brew?.bleedSize?.bottom, inner: brew?.bleedSize?.inner, outer: brew?.bleedSize?.outer }; metadata.bleedSize = { top: brew?.bleedSize?.top, bottom: brew?.bleedSize?.bottom, inner: brew?.bleedSize?.inner, outer: brew?.bleedSize?.outer };
metadata.safetySpace = { top: brew?.safetySpace?.top, bottom: brew?.safetySpace?.bottom, outer: brew?.safetySpace?.outer, inner: brew?.safetySpace?.inner }; metadata.safetySpace = { top: brew?.safetySpace?.top, bottom: brew?.safetySpace?.bottom, outer: brew?.safetySpace?.outer, inner: brew?.safetySpace?.inner };
metadata.trimSize = { width: brew?.trimSize?.width, height: brew?.trimSize?.height }; metadata.trimSize = { width: brew?.trimSize?.width, height: brew?.trimSize?.height };
metadata.columns = brew?.columns;
metadata.columnGutter = brew?.columnGutter;
metadata.license = brew?.license;
metadata.legalAuthors = brew?.legalAuthors;
text = `\`\`\`metadata\n` + text = `\`\`\`metadata\n` +
`${yaml.dump(metadata)}\n` + `${yaml.dump(metadata)}` +
`\`\`\`\n\n` + `\`\`\`\n\n` +
`${text}`; `${text}`;
return text; return text;
@@ -238,28 +218,14 @@ const api = {
return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title') return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title')
.slice(0, MAX_TITLE_LENGTH); .slice(0, MAX_TITLE_LENGTH);
}, },
excludePropsFromUpdate : (brew)=>{ excludePropsFromUpdate : (brew)=>{ //Remove props that should not update/overwrite when editing
// Remove undesired properties return _.omit(brew, ['_id', 'views', 'lastViewed']);
const modified = _.clone(brew);
const propsToExclude = ['_id', 'views', 'lastViewed'];
for (const prop of propsToExclude) {
delete modified[prop];
}
return modified;
}, },
excludeGoogleProps : (brew)=>{ excludeGoogleProps : (brew)=>{ //Remove props that should not be saved to Google (only stored to Homebrewery stub)
const modified = _.clone(brew); return _.omit(brew, ['version', 'tags', 'systems', 'published', 'authors', 'invitedAuthors', 'owner', 'views', 'thumbnail']);
const propsToExclude = ['version', 'tags', 'systems', 'published', 'authors', 'owner', 'views', 'thumbnail'];
for (const prop of propsToExclude) {
delete modified[prop];
}
return modified;
}, },
excludeStubProps : (brew)=>{ excludeStubProps : (brew)=>{ //Remove props that should not be saved to stub-only brews
const propsToExclude = ['text', 'textBin']; brew.text = brew.textBin = undefined; // Can't use _.omit because this brew is a Mongoose object
for (const prop of propsToExclude) {
brew[prop] = undefined;
}
return brew; return brew;
}, },
beforeNewSave : (account, brew)=>{ beforeNewSave : (account, brew)=>{
@@ -278,9 +244,7 @@ const api = {
newGoogleBrew : async (account, brew, res)=>{ newGoogleBrew : async (account, brew, res)=>{
const oAuth2Client = GoogleActions.authCheck(account, res); const oAuth2Client = GoogleActions.authCheck(account, res);
const newBrew = api.excludeGoogleProps(brew); return await GoogleActions.newGoogleBrew(oAuth2Client, api.excludeGoogleProps(brew));
return await GoogleActions.newGoogleBrew(oAuth2Client, newBrew);
}, },
newBrew : async (req, res)=>{ newBrew : async (req, res)=>{
const brew = req.body; const brew = req.body;
@@ -292,7 +256,7 @@ const api = {
api.beforeNewSave(req.account, brew); api.beforeNewSave(req.account, brew);
const newHomebrew = new HomebrewModel(brew); let newHomebrew = new HomebrewModel(brew);
newHomebrew.editId = nanoid(12); newHomebrew.editId = nanoid(12);
newHomebrew.shareId = nanoid(12); newHomebrew.shareId = nanoid(12);
@@ -301,15 +265,10 @@ const api = {
googleId = await api.newGoogleBrew(req.account, newHomebrew, res); googleId = await api.newGoogleBrew(req.account, newHomebrew, res);
if(!googleId) return; if(!googleId) return;
api.excludeStubProps(newHomebrew); newHomebrew = api.excludeStubProps(newHomebrew);
newHomebrew.googleId = googleId; newHomebrew.googleId = googleId;
} else {
// Compress brew text to binary before saving
newHomebrew.textBin = zlib.deflateRawSync(newHomebrew.text);
// Delete the non-binary text field since it's not needed anymore
newHomebrew.text = undefined;
} }
saved = await newHomebrew.save() saved = await newHomebrew.save()
.catch((err)=>{ .catch((err)=>{
console.error(err, err.toString(), err.stack); console.error(err, err.toString(), err.stack);
@@ -380,7 +339,6 @@ const api = {
return res.status(200).send(returnObj); return res.status(200).send(returnObj);
}, },
updateBrew : async (req, res)=>{ updateBrew : async (req, res)=>{
// 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;
@@ -406,16 +364,17 @@ const api = {
const patches = parsePatch(brewFromClient.patches); const patches = parsePatch(brewFromClient.patches);
// Patch to a throwaway variable while parallelizing - we're more concerned with error/no error. // Patch to a throwaway variable while parallelizing - we're more concerned with error/no error.
result = applyPatches(patches, encodeURI(brewFromServer.text)); result = applyPatches(patches, encodeURI(brewFromServer.text));
const failedPatches = patches.map((patch, index)=>{if(!result[1][index]){ return patch; }}); const failedPatchCount = result[1].filter((patchApplied)=>!patchApplied).length; //Count how many patches failed to apply
if(failedPatches > 0){ if(failedPatchCount){
throw (`Patch failure: ${failedPatches}/${result[1].length} did not apply`); throw (`Patch failure: ${failedPatchCount}/${result[1].length} did not apply`);
} }
if(decodeURI(result[0]) != brewFromClient.text){ patchedText = decodeURI(result[0]);
if(patchedText != brewFromClient.text){
debugTextMismatch(brewFromClient.text, patchedText, `edit/${brewFromClient.editId}`);
throw ('Patches did not apply cleanly, text mismatch detected'); throw ('Patches did not apply cleanly, text mismatch detected');
} }
// brew.text = applyPatches(patches, brewFromServer.text)[0]; // brew.text = applyPatches(patches, brewFromServer.text)[0];
} catch (err) { } catch (err) {
debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`);
console.error('Failed to apply patches:', { console.error('Failed to apply patches:', {
// patches : brewFromClient.patches, // patches : brewFromClient.patches,
// result : result, // result : result,
@@ -426,77 +385,63 @@ const api = {
// throw err; // rethrow to preserve the 500 behavior // throw err; // rethrow to preserve the 500 behavior
} }
let brew = _.assign(brewFromServer, brewFromClient); let brew = _.assign(brewFromServer, brewFromClient);
migrateSystemsToTags(brew);
brew = migrateSystemsToTags(brew);
brew.title = brew.title.trim(); brew.title = brew.title.trim();
brew.description = brew.description.trim() || ''; brew.description = brew.description.trim() || '';
brew.text = api.mergeBrewText(brew); brew.text = api.mergeBrewText(brew);
brew.updatedAt = new Date();
const googleId = brew.googleId; brew.version = (brew.version || 1) + 1;
const { saveToGoogle, removeFromGoogle } = req.query;
let afterSave = async ()=>true;
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 ()=>{
return await api.deleteGoogleBrew(req.account, googleId, brew.editId, res)
.catch((err)=>{
console.error(err);
res.status(err?.status || err?.response?.status || 500).send(err.message || err);
});
};
brew.googleId = undefined;
} else if(!brew.googleId && saveToGoogle) {
// If we don't have a google id and the user wants to save to google, create the google brew and set the google id on the brew
brew.googleId = await api.newGoogleBrew(req.account, api.excludeGoogleProps(brew), res);
if(!brew.googleId) return;
} else if(brew.googleId) {
// If the google id exists and no other actions are being performed, update the google brew
const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew), req.ip);
if(!updated) return;
}
if(brew.googleId) {
// If the google id exists after all those actions, exclude the props that are stored in google and aren't needed for rendering the brew items
api.excludeStubProps(brew);
} else {
// Compress brew text to binary before saving
brew.textBin = zlib.deflateRawSync(brew.text);
// Delete the non-binary text field since it's not needed anymore
brew.text = undefined;
}
brew.updatedAt = new Date();
brew.version = (brew.version || 1) + 1;
if(req.account) { if(req.account) {
brew.authors = _.uniq(_.concat(brew.authors, req.account.username)); brew.authors = _.uniq(_.concat(brew.authors, req.account.username));
brew.invitedAuthors = _.uniq(_.filter(brew.invitedAuthors, (a)=>req.account.username !== a)); brew.invitedAuthors = _.uniq(_.filter(brew.invitedAuthors, (a)=>req.account.username !== a));
} }
// define a function to catch our save errors //=====----- Update to or remove from Google Drive -----=========//
const saveError = (err)=>{ const originalGoogleId = brew.googleId;
console.error(err); const { saveToGoogle, removeFromGoogle } = req.query;
res.status(err.status || 500).send(err.message || 'Unable to save brew to Homebrewery database');
}; if(brew.googleId && removeFromGoogle) { // If removing from google, remove the brew's googleId
let saved; brew.googleId = undefined;
if(!brew._id) {
// if the brew does not have a stub id, create and save it, then write the new value back to the brew.
saved = await new HomebrewModel(brew).save().catch(saveError);
} else {
// if the brew does have a stub id, update it using the stub id as the key.
brew = _.assign(await HomebrewModel.findOne({ _id: brew._id }), brew);
saved = await brew.save()
.catch(saveError);
} }
else if(!brew.googleId && saveToGoogle) { // If adding to google, create new google document and record the googleId
brew.googleId = await api.newGoogleBrew(req.account, brew, res);
if(!brew.googleId) return;
}
else if(brew.googleId) { // If already on google, update the google brew
const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew), req.ip);
if(!updated) return;
}
//=====----- Update to Mongo -----=====//
if(brew.googleId) {
// If google id exists after above actions, Mongo will store only a stub; exclude non-stub properties from update
brew = api.excludeStubProps(brew);
}
if(brew._id) // if the brew has a stub id, fetch and merge into Mongo stub document
brew = _.assign(await HomebrewModel.findOne({ _id: brew._id }), brew);
else // if not, create a new Mongo document
brew = new HomebrewModel(brew);
const saved = await brew.save()
.catch((err)=>{
console.error(err);
res.status(err.status || 500).send(err.message || 'Unable to save brew to Homebrewery database');
});
if(!saved) return; if(!saved) return;
// Call and wait for afterSave to complete
const after = await afterSave(); if(originalGoogleId && removeFromGoogle) {
if(!after) return; const deleted = await api.deleteGoogleBrew(req.account, originalGoogleId, brew.editId, res)
.catch((err)=>{
console.error(err);
res.status(err?.status || err?.response?.status || 500).send(err.message || err);
});
if(!deleted) return;
}
saved.textBin = undefined; // Remove textBin from the saved object to save bandwidth saved.textBin = undefined; // Remove textBin from the saved object to save bandwidth
@@ -549,8 +494,6 @@ const api = {
if(shouldDeleteGoogleBrew) { if(shouldDeleteGoogleBrew) {
// When there are still authors remaining, we delete the google brew but store the full brew in the Homebrewery database // When there are still authors remaining, we delete the google brew but store the full brew in the Homebrewery database
brew.googleId = undefined; brew.googleId = undefined;
brew.textBin = zlib.deflateRawSync(brew.text);
brew.text = undefined;
} }
brew.markModified('authors'); //Mongo will not properly update arrays without markModified() brew.markModified('authors'); //Mongo will not properly update arrays without markModified()
await brew.save() await brew.save()
+70 -63
View File
@@ -1,6 +1,7 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import { splitTextStyleAndMetadata } from '../shared/helpers.js'; import { splitTextStyleAndMetadata } from '../shared/helpers.js';
import dedent from 'dedent';
describe('Tests for api', ()=>{ describe('Tests for api', ()=>{
let api; let api;
@@ -493,35 +494,35 @@ describe('Tests for api', ()=>{
}, },
}); });
expect(result).toEqual(`\`\`\`metadata expect(result).toEqual(dedent`
title: some title \`\`\`metadata
description: this is a description title: some title
tags: description: this is a description
- something tags:
- fun - something
renderer: v3 - fun
theme: phb renderer: v3
bleedSize: theme: phb
top: .125in columns: '2'
bottom: .125in columnGutter: .125in
inner: .125in license: None
outer: .125in legalAuthors: ''
safetySpace: bleedSize:
top: .25in top: .125in
bottom: .25in bottom: .125in
outer: .25in inner: .125in
inner: .5in outer: .125in
trimSize: safetySpace:
width: 8.5in top: .25in
height: 11in bottom: .25in
columns: '2' outer: .25in
columnGutter: .125in inner: .5in
license: None trimSize:
legalAuthors: '' width: 8.5in
height: 11in
\`\`\`
\`\`\` brew`);
brew`);
}); });
it('should set metadata and style', ()=>{ it('should set metadata and style', ()=>{
@@ -556,39 +557,39 @@ brew`);
}, },
}); });
expect(result).toEqual(`\`\`\`metadata expect(result).toEqual(dedent`
title: some title \`\`\`metadata
description: this is a description title: some title
tags: description: this is a description
- something tags:
- fun - something
renderer: v3 - fun
theme: phb renderer: v3
bleedSize: theme: phb
top: .125in columns: '2'
bottom: .125in columnGutter: .125in
inner: .125in license: None
outer: .125in legalAuthors: ''
safetySpace: bleedSize:
top: .25in top: .125in
bottom: .25in bottom: .125in
outer: .25in inner: .125in
inner: .5in outer: .125in
trimSize: safetySpace:
width: 8.5in top: .25in
height: 11in bottom: .25in
columns: '2' outer: .25in
columnGutter: .125in inner: .5in
license: None trimSize:
legalAuthors: '' width: 8.5in
height: 11in
\`\`\`
\`\`\` \`\`\`css
hello yes i am css
\`\`\`
\`\`\`css brew`);
hello yes i am css
\`\`\`
brew`);
}); });
}); });
describe('exclusion methods', ()=>{ describe('exclusion methods', ()=>{
@@ -700,8 +701,15 @@ brew`);
shareId : expect.any(String), shareId : expect.any(String),
style : undefined, style : undefined,
tags : [], tags : [],
text : undefined, text : '```metadata\n' +
textBin : expect.objectContaining({}), 'title: asdf\n' +
'bleedSize: {}\n' +
'safetySpace: {}\n' +
'trimSize: {}' +
'\n' +
'```\n' +
'\n' +
'asdf',
theme : '5ePHB', theme : '5ePHB',
thumbnail : '', thumbnail : '',
title : 'asdf', title : 'asdf',
@@ -1233,8 +1241,7 @@ brew`);
expect(saveFunc).toHaveBeenCalled(); expect(saveFunc).toHaveBeenCalled();
expect(saved.authors).toEqual(['test2']); expect(saved.authors).toEqual(['test2']);
expect(saved.googleId).toEqual(undefined); expect(saved.googleId).toEqual(undefined);
expect(saved.text).toEqual(undefined); expect(saved.text).not.toEqual(undefined);
expect(saved.textBin).not.toEqual(undefined);
}); });
it('should retain google brew and update stub when multiple authors and extra author requests deletion', async ()=>{ it('should retain google brew and update stub when multiple authors and extra author requests deletion', async ()=>{
+11 -3
View File
@@ -32,14 +32,21 @@ const HomebrewSchema = mongoose.Schema({
lock : { type: Object, index: true } lock : { type: Object, index: true }
}, { versionKey: false }); }, { versionKey: false });
HomebrewSchema.pre('save', function() {
if(this.text !== undefined) {
this.textBin = zlib.deflateRawSync(this.text);
this.text = undefined;
}
});
HomebrewSchema.statics.increaseView = async function(query) { HomebrewSchema.statics.increaseView = async function(query) {
const brew = await Homebrew.findOne(query).exec(); const brew = await Homebrew.findOne(query).exec();
brew.lastViewed = new Date(); brew.lastViewed = new Date();
brew.views = brew.views + 1; brew.views = brew.views + 1;
await brew.save() await brew.save()
.catch((err)=>{ .catch((err)=>{
return err; return err;
}); });
return brew; return brew;
}; };
@@ -51,6 +58,7 @@ HomebrewSchema.statics.get = async function(query, fields=null){
if(!_.isNil(brew.textBin)) { // Uncompress zipped text field if(!_.isNil(brew.textBin)) { // Uncompress zipped text field
const unzipped = zlib.inflateRawSync(brew.textBin); const unzipped = zlib.inflateRawSync(brew.textBin);
brew.text = unzipped.toString(); brew.text = unzipped.toString();
brew.textBin = undefined;
} }
return brew; return brew;
}; };