diff --git a/server/homebrew.api.js b/server/homebrew.api.js index aa1f1f84a..c572b2cec 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -2,7 +2,6 @@ import _ from 'lodash'; import { model as HomebrewModel } from './homebrew.model.js'; import express from 'express'; -import zlib from 'zlib'; import GoogleActions from './googleActions.js'; import { hbfm } from 'marked-hbfm'; import * as yaml from 'js-yaml'; @@ -27,17 +26,8 @@ const isStaticTheme = (renderer, themeName)=>{ return Themes[renderer]?.[themeName] !== undefined; }; -// const getTopBrews = (cb) => { -// HomebrewModel.find().sort({ views: -1 }).limit(5).exec(function(err, brews) { -// cb(brews); -// }); -// }; - - const migrateSystemsToTags = (brew)=>{ - if(!('systems' in brew)) return brew; - - if(!Array.isArray(brew.systems) || brew.systems.length === 0) { + if(!('systems' in brew) || !Array.isArray(brew.systems) || brew.systems.length === 0) { brew.systems = undefined; return brew; } @@ -108,19 +98,17 @@ const api = { const brews = await HomebrewModel.getByUser(username, true, fields, { tags: { $in: ['meta:theme', 'meta:Theme'] } }); - if(brews) { - for (const brew of brews) { - userThemes[brew.renderer] ??= {}; - userThemes[brew.renderer][brew.shareId] = { - name : brew.title, - renderer : brew.renderer, - baseTheme : brew.theme, - baseSnippets : false, - author : brew.authors[0], - path : brew.shareId, - thumbnail : brew.thumbnail || '/assets/naturalCritLogoWhite.svg' - }; - } + for (const brew of brews) { + userThemes[brew.renderer] ??= {}; + userThemes[brew.renderer][brew.shareId] = { + name : brew.title, + renderer : brew.renderer, + baseTheme : brew.theme, + baseSnippets : false, + author : brew.authors[0], + path : brew.shareId, + thumbnail : brew.thumbnail || '/assets/naturalCritLogoWhite.svg' + }; } return userThemes; @@ -189,18 +177,14 @@ const api = { stub.renderer = stub.renderer || undefined; // Clear empty strings stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields - - - const fixedStub = migrateSystemsToTags(stub); - req.brew = fixedStub; + req.brew = migrateSystemsToTags(stub); splitTextStyleAndMetadata(req.brew); next(); }; }, getCSS : async (req, res)=>{ 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({ 'Cache-Control' : 'no-cache', @@ -216,19 +200,15 @@ const api = { `\`\`\`\n\n` + `${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; 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.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.columns = brew?.columns; - metadata.columnGutter = brew?.columnGutter; - metadata.license = brew?.license; - metadata.legalAuthors = brew?.legalAuthors; text = `\`\`\`metadata\n` + - `${yaml.dump(metadata)}\n` + + `${yaml.dump(metadata)}` + `\`\`\`\n\n` + `${text}`; return text; @@ -238,28 +218,14 @@ const api = { return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title') .slice(0, MAX_TITLE_LENGTH); }, - excludePropsFromUpdate : (brew)=>{ - // Remove undesired properties - const modified = _.clone(brew); - const propsToExclude = ['_id', 'views', 'lastViewed']; - for (const prop of propsToExclude) { - delete modified[prop]; - } - return modified; + excludePropsFromUpdate : (brew)=>{ //Remove props that should not update/overwrite when editing + return _.omit(brew, ['_id', 'views', 'lastViewed']); }, - excludeGoogleProps : (brew)=>{ - const modified = _.clone(brew); - const propsToExclude = ['version', 'tags', 'systems', 'published', 'authors', 'owner', 'views', 'thumbnail']; - for (const prop of propsToExclude) { - delete modified[prop]; - } - return modified; + excludeGoogleProps : (brew)=>{ //Remove props that should not be saved to Google (only stored to Homebrewery stub) + return _.omit(brew, ['version', 'tags', 'systems', 'published', 'authors', 'invitedAuthors', 'owner', 'views', 'thumbnail']); }, - excludeStubProps : (brew)=>{ - const propsToExclude = ['text', 'textBin']; - for (const prop of propsToExclude) { - brew[prop] = undefined; - } + excludeStubProps : (brew)=>{ //Remove props that should not be saved to stub-only brews + brew.text = brew.textBin = undefined; // Can't use _.omit because this brew is a Mongoose object return brew; }, beforeNewSave : (account, brew)=>{ @@ -278,9 +244,7 @@ const api = { newGoogleBrew : async (account, brew, res)=>{ const oAuth2Client = GoogleActions.authCheck(account, res); - const newBrew = api.excludeGoogleProps(brew); - - return await GoogleActions.newGoogleBrew(oAuth2Client, newBrew); + return await GoogleActions.newGoogleBrew(oAuth2Client, api.excludeGoogleProps(brew)); }, newBrew : async (req, res)=>{ const brew = req.body; @@ -292,7 +256,7 @@ const api = { api.beforeNewSave(req.account, brew); - const newHomebrew = new HomebrewModel(brew); + let newHomebrew = new HomebrewModel(brew); newHomebrew.editId = nanoid(12); newHomebrew.shareId = nanoid(12); @@ -301,15 +265,10 @@ const api = { googleId = await api.newGoogleBrew(req.account, newHomebrew, res); if(!googleId) return; - api.excludeStubProps(newHomebrew); + newHomebrew = api.excludeStubProps(newHomebrew); 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() .catch((err)=>{ console.error(err, err.toString(), err.stack); @@ -380,7 +339,6 @@ const api = { return res.status(200).send(returnObj); }, 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 brewFromServer = req.brew; @@ -406,16 +364,17 @@ const api = { const patches = parsePatch(brewFromClient.patches); // Patch to a throwaway variable while parallelizing - we're more concerned with error/no error. result = applyPatches(patches, encodeURI(brewFromServer.text)); - const failedPatches = patches.map((patch, index)=>{if(!result[1][index]){ return patch; }}); - if(failedPatches > 0){ - throw (`Patch failure: ${failedPatches}/${result[1].length} did not apply`); + const failedPatchCount = result[1].filter((patchApplied)=>!patchApplied).length; //Count how many patches failed to apply + if(failedPatchCount){ + 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'); } // 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, // result : result, @@ -426,77 +385,63 @@ const api = { // throw err; // rethrow to preserve the 500 behavior } - let brew = _.assign(brewFromServer, brewFromClient); - - migrateSystemsToTags(brew); + let brew = _.assign(brewFromServer, brewFromClient); + brew = migrateSystemsToTags(brew); brew.title = brew.title.trim(); brew.description = brew.description.trim() || ''; brew.text = api.mergeBrewText(brew); - - const googleId = brew.googleId; - 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; - + brew.updatedAt = new Date(); + brew.version = (brew.version || 1) + 1; if(req.account) { brew.authors = _.uniq(_.concat(brew.authors, req.account.username)); brew.invitedAuthors = _.uniq(_.filter(brew.invitedAuthors, (a)=>req.account.username !== a)); } - // define a function to catch our save errors - const saveError = (err)=>{ - console.error(err); - res.status(err.status || 500).send(err.message || 'Unable to save brew to Homebrewery database'); - }; - let saved; - 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); + //=====----- Update to or remove from Google Drive -----=========// + const originalGoogleId = brew.googleId; + const { saveToGoogle, removeFromGoogle } = req.query; + + if(brew.googleId && removeFromGoogle) { // If removing from google, remove the brew's googleId + brew.googleId = undefined; } + 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; - // Call and wait for afterSave to complete - const after = await afterSave(); - if(!after) return; + + if(originalGoogleId && removeFromGoogle) { + 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 @@ -549,8 +494,6 @@ const api = { if(shouldDeleteGoogleBrew) { // When there are still authors remaining, we delete the google brew but store the full brew in the Homebrewery database brew.googleId = undefined; - brew.textBin = zlib.deflateRawSync(brew.text); - brew.text = undefined; } brew.markModified('authors'); //Mongo will not properly update arrays without markModified() await brew.save() diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js index 8bda5407b..ad6d3ac7e 100644 --- a/server/homebrew.api.spec.js +++ b/server/homebrew.api.spec.js @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ import { splitTextStyleAndMetadata } from '../shared/helpers.js'; +import dedent from 'dedent'; describe('Tests for api', ()=>{ let api; @@ -493,35 +494,35 @@ describe('Tests for api', ()=>{ }, }); - expect(result).toEqual(`\`\`\`metadata -title: some title -description: this is a description -tags: - - something - - fun -renderer: v3 -theme: phb -bleedSize: - top: .125in - bottom: .125in - inner: .125in - outer: .125in -safetySpace: - top: .25in - bottom: .25in - outer: .25in - inner: .5in -trimSize: - width: 8.5in - height: 11in -columns: '2' -columnGutter: .125in -license: None -legalAuthors: '' + expect(result).toEqual(dedent` + \`\`\`metadata + title: some title + description: this is a description + tags: + - something + - fun + renderer: v3 + theme: phb + columns: '2' + columnGutter: .125in + license: None + legalAuthors: '' + bleedSize: + top: .125in + bottom: .125in + inner: .125in + outer: .125in + safetySpace: + top: .25in + bottom: .25in + outer: .25in + inner: .5in + trimSize: + width: 8.5in + height: 11in + \`\`\` -\`\`\` - -brew`); + brew`); }); it('should set metadata and style', ()=>{ @@ -556,39 +557,39 @@ brew`); }, }); - expect(result).toEqual(`\`\`\`metadata -title: some title -description: this is a description -tags: - - something - - fun -renderer: v3 -theme: phb -bleedSize: - top: .125in - bottom: .125in - inner: .125in - outer: .125in -safetySpace: - top: .25in - bottom: .25in - outer: .25in - inner: .5in -trimSize: - width: 8.5in - height: 11in -columns: '2' -columnGutter: .125in -license: None -legalAuthors: '' + expect(result).toEqual(dedent` + \`\`\`metadata + title: some title + description: this is a description + tags: + - something + - fun + renderer: v3 + theme: phb + columns: '2' + columnGutter: .125in + license: None + legalAuthors: '' + bleedSize: + top: .125in + bottom: .125in + inner: .125in + outer: .125in + safetySpace: + top: .25in + bottom: .25in + outer: .25in + inner: .5in + trimSize: + width: 8.5in + height: 11in + \`\`\` -\`\`\` + \`\`\`css + hello yes i am css + \`\`\` -\`\`\`css -hello yes i am css -\`\`\` - -brew`); + brew`); }); }); describe('exclusion methods', ()=>{ @@ -700,8 +701,15 @@ brew`); shareId : expect.any(String), style : undefined, tags : [], - text : undefined, - textBin : expect.objectContaining({}), + text : '```metadata\n' + + 'title: asdf\n' + + 'bleedSize: {}\n' + + 'safetySpace: {}\n' + + 'trimSize: {}' + + '\n' + + '```\n' + + '\n' + + 'asdf', theme : '5ePHB', thumbnail : '', title : 'asdf', @@ -1233,8 +1241,7 @@ brew`); expect(saveFunc).toHaveBeenCalled(); expect(saved.authors).toEqual(['test2']); expect(saved.googleId).toEqual(undefined); - expect(saved.text).toEqual(undefined); - expect(saved.textBin).not.toEqual(undefined); + expect(saved.text).not.toEqual(undefined); }); it('should retain google brew and update stub when multiple authors and extra author requests deletion', async ()=>{ diff --git a/server/homebrew.model.js b/server/homebrew.model.js index e923ac928..eceb08440 100644 --- a/server/homebrew.model.js +++ b/server/homebrew.model.js @@ -32,14 +32,21 @@ const HomebrewSchema = mongoose.Schema({ lock : { type: Object, index: true } }, { 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) { const brew = await Homebrew.findOne(query).exec(); brew.lastViewed = new Date(); brew.views = brew.views + 1; await brew.save() - .catch((err)=>{ - return err; - }); + .catch((err)=>{ + return err; + }); return brew; }; @@ -51,6 +58,7 @@ HomebrewSchema.statics.get = async function(query, fields=null){ if(!_.isNil(brew.textBin)) { // Uncompress zipped text field const unzipped = zlib.inflateRawSync(brew.textBin); brew.text = unzipped.toString(); + brew.textBin = undefined; } return brew; };