diff --git a/.gitattributes b/.gitattributes index 20eac6017..2f90f4172 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ -package-lock.json binary \ No newline at end of file +package-lock.json binary + +*.json text eol=lf \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..b02835726 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,36 @@ + + +## Description + + +## Related Issues or Discussions + +- Closes # + +## QA Instructions, Screenshots, Recordings + +_Please replace this line with instructions on how to test or view your changes, as well as any before/after +images for UI changes._ + +### Reviewer Checklist + +_Please replace the list below with specific features you want reviewers to look at._ + +*Reviewers, refer to this list when testing features, or suggest new items * +- [ ] Verify new features are functional + - [ ] Feature A does X + - [ ] Feature B does Y +- [ ] Verify old features have not broken + - [ ] Feature Z can still be used +- [ ] Test for edge cases / try to break things + - [ ] Feature A handles negative numbers +- [ ] Identify opportunities for simplification and refactoring +- [ ] Check for code legibility and appropriate comments + +
Copy this list diff --git a/changelog.md b/changelog.md index 012663b6a..9d1ddf32d 100644 --- a/changelog.md +++ b/changelog.md @@ -84,9 +84,54 @@ pre { ## changelog For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). -### Tuesday 8/27/2024 - v3.14.2 -{{taskList +### Wednesday 9/04/2024 - v3.15.0 +{{taskList +##### 5e-Cleric, abquintic, calculuschild, Gazook89, G-Ambatte, Ericsheid, Kaiburr + +* [x] New {{openSans **VAULT** {{fas,fa-dungeon}}}} page πŸŽ‰πŸŽ‰πŸŽ‰ +: +All **PUBLISHED** brews ({{openSans :fas_circle_info: **Properties**}} menu) will be searchable, by title or author, and filtered by renderer. More features and adjustments will be coming. +: +Note: If any of your own brews are not showing up in search (particularly if stored on Google Drive), please edit and re-save to ensure our database has the data needed from document to be searchable. + +Fixes issue [#697](https://github.com/naturalcrit/homebrewery/issues/697) + +##### Gazook89 + +* [x] Auto-focus on text editor when switching editor tabs +}} + +### Wednesday 8/28/2024 - v3.14.3 + +{{taskList +##### calculuschild, G-Ambatte + +* [x] New {{openSans **IMAGES β†’ {{fac,image-wrap-left}} IMAGE WRAP LEFT/RIGHT**}} snippets + +Fixes issue [#380](https://github.com/naturalcrit/homebrewery/issues/380) + +* [x] Fix v3.14.2 bug with `κž‰κž‰κž‰κž‰` failing after tables + +##### 5e-Cleric + +* [x] Fix Account page crash when not logged in + +Fixes issue [#3605](https://github.com/naturalcrit/homebrewery/issues/3605) + +##### abquintic + +* [x] Fix jump hotkeys conflicting with `CTRL + SHIFT`. Preview and Source movement shortcuts now use `CTRL + SHIFT + META + LEFT\RIGHTARROW` + +##### G-Ambatte + +* [x] Fix display issue with image wrap icons +}} + + +### Tuesday 8/27/2024 - v3.14.2 + +{{taskList ##### calculuschild * [x] Reroute invalid urls to homepage @@ -119,7 +164,7 @@ Fixes issues [#3572](https://github.com/naturalcrit/homebrewery/issues/3572) Fixes issues [#1430](https://github.com/naturalcrit/homebrewery/issues/1430) -* [x] Fix colon `:::` being parsed in codeblocks +* [x] Fix colon `κž‰κž‰κž‰κž‰` being parsed in codeblocks * [x] Prevent crashes when loading undefined renderer or theme bundle @@ -133,12 +178,11 @@ Fixes issues [#1430](https://github.com/naturalcrit/homebrewery/issues/1430) ##### 5e-Cleric, Gazook89 * [x] Viewer tools for zoom/page navigation - }} ### Tuesday 8/13/2024 - v3.14.1 -{{taskList +{{taskList ##### abquintic * [x] Allow Table of Contents to flow across columns @@ -181,16 +225,13 @@ Fixes issues [#3613](https://github.com/naturalcrit/homebrewery/issues/3613) Fixes issues [#3622](https://github.com/naturalcrit/homebrewery/issues/3622) - ##### calculuschild * [x] Fix `/migrate` page using an editor context instead of share context - ##### 5e-Cleric * [x] Fix Monster Stat Blocks losing color in Safari - }} \page diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx index f3b284a93..7268e4b34 100644 --- a/client/homebrew/brewRenderer/brewRenderer.jsx +++ b/client/homebrew/brewRenderer/brewRenderer.jsx @@ -1,7 +1,7 @@ /*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/ require('./brewRenderer.less'); const React = require('react'); -const { useState, useRef, useEffect } = React; +const { useState, useRef, useEffect, useCallback } = React; const _ = require('lodash'); const MarkdownLegacy = require('naturalcrit/markdownLegacy.js'); @@ -49,23 +49,25 @@ let rawPages = []; const BrewRenderer = (props)=>{ props = { - text : '', - style : '', - renderer : 'legacy', - theme : '5ePHB', - lang : '', - errors : [], - currentEditorPage : 0, - themeBundle : {}, + text : '', + style : '', + renderer : 'legacy', + theme : '5ePHB', + lang : '', + errors : [], + currentEditorCursorPageNum : 0, + currentEditorViewPageNum : 0, + currentBrewRendererPageNum : 0, + themeBundle : {}, + onPageChange : ()=>{}, ...props }; const [state, setState] = useState({ - height : PAGE_HEIGHT, - isMounted : false, - visibility : 'hidden', - zoom : 100, - currentPageNumber : 1, + height : PAGE_HEIGHT, + isMounted : false, + visibility : 'hidden', + zoom : 100 }); const mainRef = useRef(null); @@ -87,25 +89,22 @@ const BrewRenderer = (props)=>{ })); }; - const getCurrentPage = (e)=>{ + const updateCurrentPage = useCallback(_.throttle((e)=>{ const { scrollTop, clientHeight, scrollHeight } = e.target; const totalScrollableHeight = scrollHeight - clientHeight; - const currentPageNumber = Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length); + const currentPageNumber = Math.max(Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length), 1); - setState((prevState)=>({ - ...prevState, - currentPageNumber : currentPageNumber || 1 - })); - }; + props.onPageChange(currentPageNumber); + }, 200), []); const isInView = (index)=>{ if(!state.isMounted) return false; - if(index == props.currentEditorPage) //Already rendered before this step + if(index == props.currentEditorCursorPageNum - 1) //Already rendered before this step return false; - if(Math.abs(index - state.currentPageNumber) <= 3) + if(Math.abs(index - props.currentBrewRendererPageNum - 1) <= 3) return true; return false; @@ -142,7 +141,7 @@ const BrewRenderer = (props)=>{ renderedPages.length = 0; // Render currently-edited page first so cross-page effects (variables, links) can propagate out first - renderedPages[props.currentEditorPage] = renderPage(rawPages[props.currentEditorPage], props.currentEditorPage); + renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1); _.forEach(rawPages, (page, index)=>{ if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){ @@ -192,7 +191,7 @@ const BrewRenderer = (props)=>{ <> {/*render dummy page while iFrame is mounting.*/} {!state.isMounted - ?
+ ?
{renderDummyPage(1)}
@@ -205,7 +204,7 @@ const BrewRenderer = (props)=>{
- + {/*render in iFrame so broken code doesn't crash the site.*/} { onClick={()=>{emitClick();}} >
diff --git a/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx b/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx index cca60bbec..cba6a629c 100644 --- a/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx +++ b/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx @@ -4,7 +4,7 @@ const _ = require('lodash'); import Dialog from '../../../components/dialog.jsx'; -const DISMISS_KEY = 'dismiss_notification12-04-23'; +const DISMISS_KEY = 'dismiss_notification04-09-24'; const DISMISS_BUTTON = ; const NotificationPopup = ()=>{ @@ -15,11 +15,12 @@ const NotificationPopup = ()=>{ This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:
    -
  • - Don't store IMAGES in Google Drive
    - Google Drive is not an image service, and will block images from being used - in brews if they get more views than expected. Google has confirmed they won't fix - this, so we recommend you look for another image hosting service such as imgur, ImgBB or Google Photos. +
  • + Search brews with our new page!
    + We have been working very hard in making this possible, now you can share your work and look at it in the new Vault page! + All PUBLISHED brews will be available to anyone searching there, by title or author, and filtering by renderer. + + More features will be coming.
  • diff --git a/client/homebrew/brewRenderer/toolBar/toolBar.jsx b/client/homebrew/brewRenderer/toolBar/toolBar.jsx index fb3b62067..73b48d778 100644 --- a/client/homebrew/brewRenderer/toolBar/toolBar.jsx +++ b/client/homebrew/brewRenderer/toolBar/toolBar.jsx @@ -11,6 +11,7 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{ const [zoomLevel, setZoomLevel] = useState(100); const [pageNum, setPageNum] = useState(currentPage); + const [toolsVisible, setToolsVisible] = useState(true); useEffect(()=>{ onZoomChange(zoomLevel); @@ -55,7 +56,7 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{ } else if(mode == 'fit'){ // find the page with the largest single dim (height or width) so that zoom can be adapted to fit it. - const minDimRatio = [...pages].reduce((minRatio, page) => Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity); + const minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity); desiredZoom = minDimRatio * 100; } @@ -67,7 +68,8 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{ }; return ( -
    +
    + {/*v=====----------------------< Zoom Controls >---------------------=====v*/}
    diff --git a/client/homebrew/editor/snippetbar/snippetbar.jsx b/client/homebrew/editor/snippetbar/snippetbar.jsx index af493c961..e19889cc7 100644 --- a/client/homebrew/editor/snippetbar/snippetbar.jsx +++ b/client/homebrew/editor/snippetbar/snippetbar.jsx @@ -5,6 +5,8 @@ const createClass = require('create-react-class'); const _ = require('lodash'); const cx = require('classnames'); +import { getHistoryItems, historyExists } from '../../utils/versionHistory.js'; + //Import all themes const ThemeSnippets = {}; ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js'); @@ -38,7 +40,8 @@ const Snippetbar = createClass({ unfoldCode : ()=>{}, updateEditorTheme : ()=>{}, cursorPos : {}, - snippetBundle : [] + snippetBundle : [], + updateBrew : ()=>{} }; }, @@ -46,7 +49,8 @@ const Snippetbar = createClass({ return { renderer : this.props.renderer, themeSelector : false, - snippets : [] + snippets : [], + historyExists : false }; }, @@ -59,18 +63,20 @@ const Snippetbar = createClass({ componentDidUpdate : async function(prevProps) { if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) { - const snippets = this.compileSnippets(); this.setState({ - snippets : snippets + snippets : this.compileSnippets() }); - } - }, + }; + this.setState({ + historyExists : historyExists(this.props.brew) + }); + }, mergeCustomizer : function(oldValue,Β newValue, key)Β { if(key == 'snippets')Β { const result = _.reverse(_.unionBy(_.reverse(newValue), _.reverse(oldValue), 'name')); // Join snippets together, with preference for the child theme over the parent theme - return _.filter(result, 'gen'); //Only keep snippets with a 'gen' property. + return result.filter((snip)=>snip.gen || snip.subsnippets); } }, @@ -138,6 +144,36 @@ const Snippetbar = createClass({ }); }, + replaceContent : function(item){ + return this.props.updateBrew(item); + }, + + renderHistoryItems : function() { + const historyItems = getHistoryItems(this.props.brew); + + return
    + {_.map(historyItems, (item, index)=>{ + if(!item.savedAt) return; + + const saveTime = new Date(item.savedAt); + const diffMs = new Date() - saveTime; + const diffSecs = Math.floor(diffMs / 1000); + + let diffString = `about ${diffSecs} seconds ago`; + + if(diffSecs > 60) diffString = `about ${Math.floor(diffSecs / 60)} minutes ago`; + if(diffSecs > (60 * 60)) diffString = `about ${Math.floor(diffSecs / (60 * 60))} hours ago`; + if(diffSecs > (24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (24 * 60 * 60))} days ago`; + if(diffSecs > (7 * 24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (7 * 24 * 60 * 60))} weeks ago`; + + return
    {this.replaceContent(item);}} > + + v{item.version} : {diffString} +
    ; + })} +
    ; + }, + renderEditorButtons : function(){ if(!this.props.showEditButtons) return; @@ -158,6 +194,10 @@ const Snippetbar = createClass({ } return
    +
    + + {this.state.historyExists && this.renderHistoryItems() } +
    diff --git a/client/homebrew/editor/snippetbar/snippetbar.less b/client/homebrew/editor/snippetbar/snippetbar.less index e0a24fac9..c50d9df4c 100644 --- a/client/homebrew/editor/snippetbar/snippetbar.less +++ b/client/homebrew/editor/snippetbar/snippetbar.less @@ -53,6 +53,21 @@ font-size : 0.75em; color : inherit; } + &.history { + .tooltipLeft('History'); + font-size : 0.75em; + color : grey; + position : relative; + &.active { + color : inherit; + } + &>.dropdown{ + right : -1px; + &>.snippet{ + padding-right : 10px; + } + } + } &.editorTheme { .tooltipLeft('Editor Themes'); font-size : 0.75em; diff --git a/client/homebrew/homebrew.jsx b/client/homebrew/homebrew.jsx index 2226c4f3f..63cf295fe 100644 --- a/client/homebrew/homebrew.jsx +++ b/client/homebrew/homebrew.jsx @@ -10,6 +10,7 @@ const UserPage = require('./pages/userPage/userPage.jsx'); const SharePage = require('./pages/sharePage/sharePage.jsx'); const NewPage = require('./pages/newPage/newPage.jsx'); const ErrorPage = require('./pages/errorPage/errorPage.jsx'); +const VaultPage = require('./pages/vaultPage/vaultPage.jsx'); const AccountPage = require('./pages/accountPage/accountPage.jsx'); const WithRoute = (props)=>{ @@ -71,6 +72,7 @@ const Homebrew = createClass({ } /> } /> } /> + }/> } /> } /> } /> diff --git a/client/homebrew/navbar/error-navitem.jsx b/client/homebrew/navbar/error-navitem.jsx index 5dd5c1eb9..f6788e6d5 100644 --- a/client/homebrew/navbar/error-navitem.jsx +++ b/client/homebrew/navbar/error-navitem.jsx @@ -111,7 +111,7 @@ const ErrorNavItem = createClass({ Looks like there was a problem retreiving the theme, or a theme that it inherits, for this brew. Verify that brew - {response.body.brewId} still exists! + {response.body.brewId} still exists!
    ; } diff --git a/client/homebrew/navbar/vault.navitem.jsx b/client/homebrew/navbar/vault.navitem.jsx new file mode 100644 index 000000000..087297011 --- /dev/null +++ b/client/homebrew/navbar/vault.navitem.jsx @@ -0,0 +1,17 @@ +const React = require('react'); + +const Nav = require('naturalcrit/nav/nav.jsx'); + +module.exports = function (props) { + return ( + + Vault + + ); +}; diff --git a/client/homebrew/pages/basePages/listPage/brewItem/brewItem.jsx b/client/homebrew/pages/basePages/listPage/brewItem/brewItem.jsx index bf0624f1c..039bc98f5 100644 --- a/client/homebrew/pages/basePages/listPage/brewItem/brewItem.jsx +++ b/client/homebrew/pages/basePages/listPage/brewItem/brewItem.jsx @@ -19,7 +19,8 @@ const BrewItem = createClass({ stubbed : true }, updateListFilter : ()=>{}, - reportError : ()=>{} + reportError : ()=>{}, + renderStorage : true }; }, @@ -95,6 +96,7 @@ const BrewItem = createClass({ }, renderStorageIcon : function(){ + if(!this.props.renderStorage) return; if(this.props.brew.googleId) { return @@ -142,10 +144,14 @@ const BrewItem = createClass({ } {brew.authors?.map((author, index)=>( - <> - {author} - {index < brew.authors.length - 1 && ', '} - ))} + + {author === 'hidden' + ? {author} + : {author} + } + {index < brew.authors.length - 1 && ', '} + + ))}
    diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 39a6d1931..18cfb2d41 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -1,8 +1,9 @@ /* eslint-disable max-lines */ require('./editPage.less'); const React = require('react'); -const createClass = require('create-react-class'); const _ = require('lodash'); +const createClass = require('create-react-class'); + const request = require('../../utils/request-middleware.js'); const { Meta } = require('vitreum/headtags'); @@ -27,6 +28,8 @@ const Markdown = require('naturalcrit/markdown.js'); const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js'); const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js'); +import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js'; + const googleDriveIcon = require('../../googleDrive.svg'); const SAVE_TIMEOUT = 3000; @@ -41,22 +44,24 @@ const EditPage = createClass({ getInitialState : function() { return { - brew : this.props.brew, - isSaving : false, - isPending : false, - alertTrashedGoogleBrew : this.props.brew.trashed, - alertLoginToTransfer : false, - saveGoogle : this.props.brew.googleId ? true : false, - confirmGoogleTransfer : false, - error : null, - htmlErrors : Markdown.validate(this.props.brew.text), - url : '', - autoSave : true, - autoSaveWarning : false, - unsavedTime : new Date(), - currentEditorPage : 0, - displayLockMessage : this.props.brew.lock || false, - themeBundle : {} + brew : this.props.brew, + isSaving : false, + isPending : false, + alertTrashedGoogleBrew : this.props.brew.trashed, + alertLoginToTransfer : false, + saveGoogle : this.props.brew.googleId ? true : false, + confirmGoogleTransfer : false, + error : null, + htmlErrors : Markdown.validate(this.props.brew.text), + url : '', + autoSave : true, + autoSaveWarning : false, + unsavedTime : new Date(), + currentEditorViewPageNum : 1, + currentEditorCursorPageNum : 1, + currentBrewRendererPageNum : 1, + displayLockMessage : this.props.brew.lock || false, + themeBundle : {} }; }, @@ -113,16 +118,27 @@ const EditPage = createClass({ this.editor.current.update(); }, + handleEditorViewPageChange : function(pageNumber){ + this.setState({ currentEditorViewPageNum: pageNumber }); + }, + + handleEditorCursorPageChange : function(pageNumber){ + this.setState({ currentEditorCursorPageNum: pageNumber }); + }, + + handleBrewRendererPageChange : function(pageNumber){ + this.setState({ currentBrewRendererPageNum: pageNumber }); + }, + handleTextChange : function(text){ //If there are errors, run the validator on every change to give quick feedback let htmlErrors = this.state.htmlErrors; if(htmlErrors.length) htmlErrors = Markdown.validate(text); this.setState((prevState)=>({ - brew : { ...prevState.brew, text: text }, - isPending : true, - htmlErrors : htmlErrors, - currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0 + brew : { ...prevState.brew, text: text }, + isPending : true, + htmlErrors : htmlErrors, }), ()=>{if(this.state.autoSave) this.trySave();}); }, @@ -150,6 +166,16 @@ const EditPage = createClass({ return !_.isEqual(this.state.brew, this.savedBrew); }, + updateBrew : function(newData){ + this.setState((prevState)=>({ + brew : { + ...prevState.brew, + style : newData.style, + text : newData.text + } + })); + }, + trySave : function(immediate=false){ if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT); if(this.hasChanges()){ @@ -202,6 +228,9 @@ const EditPage = createClass({ htmlErrors : Markdown.validate(prevState.brew.text) })); + updateHistory(this.state.brew); + versionHistoryGarbageCollection(); + const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); const brew = this.state.brew; @@ -413,6 +442,12 @@ const EditPage = createClass({ renderer={this.state.brew.renderer} userThemes={this.props.userThemes} snippetBundle={this.state.themeBundle.snippets} + updateBrew={this.updateBrew} + onCursorPageChange={this.handleEditorCursorPageChange} + onViewPageChange={this.handleEditorViewPageChange} + currentEditorViewPageNum={this.state.currentEditorViewPageNum} + currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} + currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} /> diff --git a/client/homebrew/pages/errorPage/errors/errorIndex.js b/client/homebrew/pages/errorPage/errors/errorIndex.js index 957991ad6..c5c455bbe 100644 --- a/client/homebrew/pages/errorPage/errors/errorIndex.js +++ b/client/homebrew/pages/errorPage/errors/errorIndex.js @@ -161,7 +161,7 @@ const errorIndex = (props)=>{ Please login or signup at our [login page](https://www.naturalcrit.com/login?redirect=https://homebrewery.naturalcrit.com/account).`, // Brew locked by Administrators error - '100' : dedent` + '51' : dedent` ## This brew has been locked. Only an author may request that this lock is removed. @@ -171,6 +171,11 @@ const errorIndex = (props)=>{ **Brew ID:** ${props.brew.brewId} **Brew Title:** ${props.brew.brewTitle}`, + + '90' : dedent` An unexpected error occurred while looking for these brews. + Try again in a few minutes.`, + + '91' : dedent` An unexpected error occurred while trying to get the total of brews.`, }; }; diff --git a/client/homebrew/pages/homePage/homePage.jsx b/client/homebrew/pages/homePage/homePage.jsx index 490b22596..ac3be81df 100644 --- a/client/homebrew/pages/homePage/homePage.jsx +++ b/client/homebrew/pages/homePage/homePage.jsx @@ -1,7 +1,6 @@ require('./homePage.less'); const React = require('react'); const createClass = require('create-react-class'); -const _ = require('lodash'); const cx = require('classnames'); const request = require('../../utils/request-middleware.js'); const { Meta } = require('vitreum/headtags'); @@ -10,12 +9,12 @@ const Nav = require('naturalcrit/nav/nav.jsx'); const Navbar = require('../../navbar/navbar.jsx'); const NewBrewItem = require('../../navbar/newbrew.navitem.jsx'); const HelpNavItem = require('../../navbar/help.navitem.jsx'); +const VaultNavItem = require('../../navbar/vault.navitem.jsx'); const RecentNavItem = require('../../navbar/recent.navitem.jsx').both; const AccountNavItem = require('../../navbar/account.navitem.jsx'); const ErrorNavItem = require('../../navbar/error-navitem.jsx'); const { fetchThemeBundle } = require('../../../../shared/helpers.js'); - const SplitPane = require('naturalcrit/splitPane/splitPane.jsx'); const Editor = require('../../editor/editor.jsx'); const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx'); @@ -32,11 +31,13 @@ const HomePage = createClass({ }, getInitialState : function() { return { - brew : this.props.brew, - welcomeText : this.props.brew.text, - error : undefined, - currentEditorPage : 0, - themeBundle : {} + brew : this.props.brew, + welcomeText : this.props.brew.text, + error : undefined, + currentEditorViewPageNum : 1, + currentEditorCursorPageNum : 1, + currentBrewRendererPageNum : 1, + themeBundle : {} }; }, @@ -61,10 +62,22 @@ const HomePage = createClass({ handleSplitMove : function(){ this.editor.current.update(); }, + + handleEditorViewPageChange : function(pageNumber){ + this.setState({ currentEditorViewPageNum: pageNumber }); + }, + + handleEditorCursorPageChange : function(pageNumber){ + this.setState({ currentEditorCursorPageNum: pageNumber }); + }, + + handleBrewRendererPageChange : function(pageNumber){ + this.setState({ currentBrewRendererPageNum: pageNumber }); + }, + handleTextChange : function(text){ this.setState((prevState)=>({ - brew : { ...prevState.brew, text: text }, - currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0 + brew : { ...prevState.brew, text: text }, })); }, renderNavbar : function(){ @@ -76,6 +89,7 @@ const HomePage = createClass({ } + @@ -96,12 +110,20 @@ const HomePage = createClass({ renderer={this.state.brew.renderer} showEditButtons={false} snippetBundle={this.state.themeBundle.snippets} + onCursorPageChange={this.handleEditorCursorPageChange} + onViewPageChange={this.handleEditorViewPageChange} + currentEditorViewPageNum={this.state.currentEditorViewPageNum} + currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} + currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} /> diff --git a/client/homebrew/pages/newPage/newPage.jsx b/client/homebrew/pages/newPage/newPage.jsx index 5b0f59c00..c147cd474 100644 --- a/client/homebrew/pages/newPage/newPage.jsx +++ b/client/homebrew/pages/newPage/newPage.jsx @@ -39,13 +39,15 @@ const NewPage = createClass({ const brew = this.props.brew; return { - brew : brew, - isSaving : false, - saveGoogle : (global.account && global.account.googleId ? true : false), - error : null, - htmlErrors : Markdown.validate(brew.text), - currentEditorPage : 0, - themeBundle : {} + brew : brew, + isSaving : false, + saveGoogle : (global.account && global.account.googleId ? true : false), + error : null, + htmlErrors : Markdown.validate(brew.text), + currentEditorViewPageNum : 1, + currentEditorCursorPageNum : 1, + currentBrewRendererPageNum : 1, + themeBundle : {} }; }, @@ -108,15 +110,26 @@ const NewPage = createClass({ this.editor.current.update(); }, + handleEditorViewPageChange : function(pageNumber){ + this.setState({ currentEditorViewPageNum: pageNumber }); + }, + + handleEditorCursorPageChange : function(pageNumber){ + this.setState({ currentEditorCursorPageNum: pageNumber }); + }, + + handleBrewRendererPageChange : function(pageNumber){ + this.setState({ currentBrewRendererPageNum: pageNumber }); + }, + handleTextChange : function(text){ //If there are errors, run the validator on every change to give quick feedback let htmlErrors = this.state.htmlErrors; if(htmlErrors.length) htmlErrors = Markdown.validate(text); this.setState((prevState)=>({ - brew : { ...prevState.brew, text: text }, - htmlErrors : htmlErrors, - currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0 + brew : { ...prevState.brew, text: text }, + htmlErrors : htmlErrors, })); localStorage.setItem(BREWKEY, text); }, @@ -221,6 +234,11 @@ const NewPage = createClass({ renderer={this.state.brew.renderer} userThemes={this.props.userThemes} snippetBundle={this.state.themeBundle.snippets} + onCursorPageChange={this.handleEditorCursorPageChange} + onViewPageChange={this.handleEditorViewPageChange} + currentEditorViewPageNum={this.state.currentEditorViewPageNum} + currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} + currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} /> diff --git a/client/homebrew/pages/userPage/userPage.jsx b/client/homebrew/pages/userPage/userPage.jsx index 01778be44..d6fe25b30 100644 --- a/client/homebrew/pages/userPage/userPage.jsx +++ b/client/homebrew/pages/userPage/userPage.jsx @@ -12,6 +12,7 @@ const Account = require('../../navbar/account.navitem.jsx'); const NewBrew = require('../../navbar/newbrew.navitem.jsx'); const HelpNavItem = require('../../navbar/help.navitem.jsx'); const ErrorNavItem = require('../../navbar/error-navitem.jsx'); +const VaultNavitem = require('../../navbar/vault.navitem.jsx'); const UserPage = createClass({ displayName : 'UserPage', @@ -66,6 +67,7 @@ const UserPage = createClass({ } + diff --git a/client/homebrew/pages/vaultPage/vaultPage.jsx b/client/homebrew/pages/vaultPage/vaultPage.jsx new file mode 100644 index 000000000..bad1fbd57 --- /dev/null +++ b/client/homebrew/pages/vaultPage/vaultPage.jsx @@ -0,0 +1,396 @@ +require('./vaultPage.less'); + +const React = require('react'); +const { useState, useEffect, useRef } = React; + +const Nav = require('naturalcrit/nav/nav.jsx'); +const Navbar = require('../../navbar/navbar.jsx'); +const RecentNavItem = require('../../navbar/recent.navitem.jsx').both; +const Account = require('../../navbar/account.navitem.jsx'); +const NewBrew = require('../../navbar/newbrew.navitem.jsx'); +const HelpNavItem = require('../../navbar/help.navitem.jsx'); +const BrewItem = require('../basePages/listPage/brewItem/brewItem.jsx'); +const SplitPane = require('../../../../shared/naturalcrit/splitPane/splitPane.jsx'); +const ErrorIndex = require('../errorPage/errors/errorIndex.js'); + +const request = require('../../utils/request-middleware.js'); + +const VaultPage = (props)=>{ + const [pageState, setPageState] = useState(parseInt(props.query.page) || 1); + + //Response state + const [brewCollection, setBrewCollection] = useState(null); + const [totalBrews, setTotalBrews] = useState(null); + const [searching, setSearching] = useState(false); + const [error, setError] = useState(null); + + + const titleRef = useRef(null); + const authorRef = useRef(null); + const countRef = useRef(null); + const v3Ref = useRef(null); + const legacyRef = useRef(null); + const submitButtonRef = useRef(null); + + useEffect(()=>{ + disableSubmitIfFormInvalid(); + loadPage(pageState, true); + }, []); + + const updateStateWithBrews = (brews, page)=>{ + setBrewCollection(brews || null); + setPageState(parseInt(page) || 1); + setSearching(false); + }; + + const updateUrl = (titleValue, authorValue, countValue, v3Value, legacyValue, page)=>{ + const url = new URL(window.location.href); + const urlParams = new URLSearchParams(url.search); + + urlParams.set('title', titleValue); + urlParams.set('author', authorValue); + urlParams.set('count', countValue); + urlParams.set('v3', v3Value); + urlParams.set('legacy', legacyValue); + urlParams.set('page', page); + + url.search = urlParams.toString(); + window.history.replaceState(null, '', url.toString()); + }; + + const performSearch = async (title, author, count, v3, legacy, page)=>{ + updateUrl(title, author, count, v3, legacy, page); + + const response = await request.get( + `/api/vault?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}&count=${count}&page=${page}` + ).catch((error)=>{ + console.log('error at loadPage: ', error); + setError(error); + updateStateWithBrews([], 1); + }); + + if(response.ok) + updateStateWithBrews(response.body.brews, page); + }; + + const loadTotal = async (title, author, v3, legacy)=>{ + setTotalBrews(null); + + const response = await request.get( + `/api/vault/total?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}` + ).catch((error)=>{ + console.log('error at loadTotal: ', error); + setError(error); + updateStateWithBrews([], 1); + }); + + if(response.ok) + setTotalBrews(response.body.totalBrews); + }; + + const loadPage = async (page, updateTotal)=>{ + if(!validateForm()) + return; + + setSearching(true); + setError(null); + + const title = titleRef.current.value || ''; + const author = authorRef.current.value || ''; + const count = countRef.current.value || 10; + const v3 = v3Ref.current.checked != false; + const legacy = legacyRef.current.checked != false; + + performSearch(title, author, count, v3, legacy, page); + + if(updateTotal) + loadTotal(title, author, v3, legacy); + }; + + const renderNavItems = ()=>( + + + + Vault: Search for brews + + + + + + + + + + ); + + const validateForm = ()=>{ + //form validity: title or author must be written, and at least one renderer set + const isTitleValid = titleRef.current.validity.valid && titleRef.current.value; + const isAuthorValid = authorRef.current.validity.valid && authorRef.current.value; + const isCheckboxChecked = legacyRef.current.checked || v3Ref.current.checked; + + const isFormValid = (isTitleValid || isAuthorValid) && isCheckboxChecked; + + return isFormValid; + }; + + const disableSubmitIfFormInvalid = ()=>{ + submitButtonRef.current.disabled = !validateForm(); + }; + + const renderForm = ()=>( +
    +

    Brew Lookup

    +
    + + + + + + + + + + + +
    + +

    Tips and tricks

    +
      +
    • + Only published brews are searchable via this tool +
    • +
    • + Usernames are case-sensitive +
    • +
    • + Use "word" to match an exact string, + and - to exclude words (at least one word must not be negated) +
    • +
    • + Some common words like "a", "after", "through", "itself", "here", etc., + are ignored in searches. The full list can be found   + + here + +
    • +
    + New features will be coming, such as filters and search by tags. +
    +
    + ); + + const renderPaginationControls = ()=>{ + if(!totalBrews) return null; + + const countInt = parseInt(props.query.count || 20); + const totalPages = Math.ceil(totalBrews / countInt); + + let startPage, endPage; + if(pageState <= 6) { + startPage = 1; + endPage = Math.min(totalPages, 10); + } else if(pageState + 4 >= totalPages) { + startPage = Math.max(1, totalPages - 9); + endPage = totalPages; + } else { + startPage = pageState - 5; + endPage = pageState + 4; + } + + const pagesAroundCurrent = new Array(endPage - startPage + 1) + .fill() + .map((_, index)=>( + loadPage(startPage + index, false)} + > + {startPage + index} + + )); + + return ( +
    + +
      + {startPage > 1 && ( + loadPage(1, false)} + > + 1 ... + + )} + {pagesAroundCurrent} + {endPage < totalPages && ( + loadPage(totalPages, false)} + > + ... {totalPages} + + )} +
    + +
    + ); + }; + + const renderFoundBrews = ()=>{ + if(searching) { + return ( +
    +

    Searching

    +
    + ); + } + + if(error) { + const errorText = ErrorIndex()[error.HBErrorCode.toString()] || ''; + + return ( +
    +

    Error: {errorText}

    +
    + ); + } + + if(!brewCollection) { + return ( +
    +

    No search yet

    +
    + ); + } + + if(brewCollection.length === 0) { + return ( +
    +

    No brews found

    +
    + ); + } + + return ( +
    + + {`Brews found: `} + {totalBrews} + + {brewCollection.map((brew, index)=>{ + return ( + + ); + })} + {renderPaginationControls()} +
    + ); + }; + + return ( +
    + + + {renderNavItems()} +
    + +
    {renderForm()}
    + +
    + {renderFoundBrews()} +
    +
    +
    +
    + ); +}; + +module.exports = VaultPage; diff --git a/client/homebrew/pages/vaultPage/vaultPage.less b/client/homebrew/pages/vaultPage/vaultPage.less new file mode 100644 index 000000000..95e6b4c69 --- /dev/null +++ b/client/homebrew/pages/vaultPage/vaultPage.less @@ -0,0 +1,362 @@ +.vaultPage { + height : 100%; + overflow-y : hidden; + background-color : #2C3E50; + + *:not(input) { user-select : none; } + + .content { + background : #2C3E50; + height: 100%; + + .dataGroup { + width : 100%; + height : 100%; + background : white; + + &.form .brewLookup { + position : relative; + padding : 50px clamp(20px, 4vw, 50px); + + small { + font-size : 10pt; + color : #555555; + + a { color : #333333; } + } + + code { + padding-inline : 5px; + background : lightgrey; + border-radius : 5px; + font-family : monospace; + } + + h1, h2, h3, h4 { + font-family : 'CodeBold'; + letter-spacing : 2px; + } + + legend { + h3 { + margin-block : 30px 20px; + font-size : 20px; + text-align : center; + border-bottom : 2px solid; + } + ul { + padding-inline : 30px 10px; + li { + margin-block : 5px; + line-height : calc(1em + 5px); + list-style : disc; + } + } + } + + &::after { + position : absolute; + top : 0; + right : 0; + left : 0; + display : block; + padding : 10px; + font-weight : 900; + color : white; + white-space : pre-wrap; + content : 'Error:\A At least one renderer should be enabled to make a search'; + background : rgb(255, 60, 60); + opacity : 0; + transition : opacity 0.5s; + } + &:not(:has(input[type='checkbox']:checked))::after { opacity : 1; } + + .formTitle { + margin : 20px 0; + font-size : 30px; + color : black; + text-align : center; + border-bottom : 2px solid; + } + + .formContents { + position : relative; + display : flex; + flex-direction : column; + + label { + display : flex; + align-items : center; + margin : 10px 0; + } + select { margin : 0 10px; } + + input { + margin : 0 10px; + + &:invalid { background : rgb(255, 188, 181); } + + &[type='checkbox'] { + position : relative; + display : inline-block; + width : 50px; + height : 30px; + font-family : 'WalterTurncoat'; + font-size : 20px; + font-weight : 800; + color : white; + letter-spacing : 2px; + appearance : none; + background : red; + isolation : isolate; + border-radius : 5px; + + &::before,&::after { + position : absolute; + inset : 0; + z-index : 5; + padding-top : 2px; + text-align : center; + } + + &::before { + display : block; + content : 'No'; + } + + &::after { + display : none; + content : 'Yes'; + } + + &:checked { + background : green; + + &::before { display : none; } + &::after { display : block; } + } + } + } + + #searchButton { + position : absolute; + right : 20px; + bottom : 0; + + i { + margin-left : 10px; + animation-duration : 1000s; + } + } + } + } + + &.resultsContainer { + display : flex; + flex-direction : column; + height : 100%; + overflow-y : auto; + font-family : 'BookInsanityRemake'; + font-size : 0.34cm; + + h3 { + font-family : 'Open Sans'; + font-weight : 900; + color : white; + } + + .foundBrews { + position : relative; + width : 100%; + height : 100%; + max-height : 100%; + padding : 50px 50px 70px 50px; + overflow-y : scroll; + background-color : #2C3E50; + + h3 { font-size : 25px; } + + &.noBrews { + display : grid; + place-items : center; + color : white; + } + + &.searching { + display : grid; + place-items : center; + color : white; + + h3 { position : relative; } + + h3.searchAnim::after { + position : absolute; + top : 50%; + right : 0; + width : max-content; + height : 1em; + content : ''; + translate : calc(100% + 5px) -50%; + animation : trailingDots 2s ease infinite; + } + } + + .totalBrews { + position : fixed; + right : 0; + bottom : 0; + z-index : 1000; + padding : 8px 10px; + font-family : 'Open Sans'; + font-size : 11px; + font-weight : 800; + color : white; + background-color : #333333; + + .searchAnim { + position : relative; + display : inline-block; + width : 3ch; + height : 1em; + } + + .searchAnim::after { + position : absolute; + top : 50%; + right : 0; + width : max-content; + height : 1em; + content : ''; + translate : -50% -50%; + animation : trailingDots 2s ease infinite; + } + } + + .brewItem { + width : 47%; + margin-right : 40px; + color : black; + isolation:isolate; + + &:after { + position:absolute; + inset:0; + display:block; + content:''; + background-image : url('/assets/parchmentBackground.jpg'); + z-index:-1; + } + + &:nth-child(even of .brewItem) { margin-right : 0; } + + h2 { + font-family : 'MrEavesRemake'; + font-size : 0.75cm; + font-weight : 800; + line-height : 0.988em; + color : var(--HB_Color_HeaderText); + } + .info { + font-family : 'ScalySansRemake'; + font-size : 1.2em; + position:relative; + z-index:2; + + >span { + margin-right : 12px; + line-height : 1.5em; + } + } + .links { + z-index:2; + } + + hr { + margin: 0px; + visibility: hidden; + } + + .thumbnail { + z-index:1; + } + } + + .paginationControls { + position : absolute; + left : 50%; + display : grid; + grid-template-areas : 'previousPage currentPage nextPage'; + grid-template-columns : 50px 1fr 50px; + place-items : center; + width : auto; + translate : -50%; + + .pages { + display : flex; + grid-area : currentPage; + justify-content : space-evenly; + width : 100%; + height : 100%; + padding : 5px 8px; + text-align : center; + + .pageNumber { + margin-inline : 1vw; + font-family : 'Open Sans'; + font-weight : 900; + color : white; + text-underline-position : under; + text-wrap : nowrap; + cursor : pointer; + + &.currentPage { + color : gold; + text-decoration : underline; + pointer-events : none; + } + + &.firstPage { margin-right : -5px; } + + &.lastPage { margin-left : -5px; } + } + } + + button { + width : max-content; + + &.previousPage { grid-area : previousPage; } + + &.nextPage { grid-area : nextPage; } + } + + } + } + } + } + } +} + +@keyframes trailingDots { + + 0%, + 32% { content : ' .'; } + + 33%, + 65% { content : ' ..'; } + + 66%, + 100% { content : ' ...'; } +} + +// media query for when the page is smaller than 1079 px in width +@media screen and (max-width : 1079px) { + .vaultPage .content { + + .dataGroup.form .brewLookup { padding : 1px 20px 20px 10px; } + + .dataGroup.resultsContainer .foundBrews .brewItem { + width : 100%; + margin-inline : auto; + } + } +} diff --git a/client/homebrew/utils/versionHistory.js b/client/homebrew/utils/versionHistory.js new file mode 100644 index 000000000..ad7c6102e --- /dev/null +++ b/client/homebrew/utils/versionHistory.js @@ -0,0 +1,116 @@ +export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY'; +export const HISTORY_SLOTS = 5; + +// History values in minutes +const DEFAULT_HISTORY_SAVE_DELAYS = { + '0' : 0, + '1' : 2, + '2' : 10, + '3' : 60, + '4' : 12 * 60, + '5' : 2 * 24 * 60 +}; + +const DEFAULT_GARBAGE_COLLECT_DELAY = 28 * 24 * 60; + +const HISTORY_SAVE_DELAYS = global.config?.historyData?.HISTORY_SAVE_DELAYS ?? DEFAULT_HISTORY_SAVE_DELAYS; +const GARBAGE_COLLECT_DELAY = global.config?.historyData?.GARBAGE_COLLECT_DELAY ?? DEFAULT_GARBAGE_COLLECT_DELAY; + + + +function getKeyBySlot(brew, slot){ + return `${HISTORY_PREFIX}-${brew.shareId}-${slot}`; +}; + +function getVersionBySlot(brew, slot){ + // Read stored brew data + // - If it exists, parse data to object + // - If it doesn't exist, pass default object + const key = getKeyBySlot(brew, slot); + const storedVersion = localStorage.getItem(key); + const output = storedVersion ? JSON.parse(storedVersion) : { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true }; + return output; +}; + +function updateStoredBrew(brew, slot = 0) { + const archiveBrew = { + title : brew.title, + text : brew.text, + style : brew.style, + version : brew.version, + shareId : brew.shareId, + savedAt : brew?.savedAt || new Date(), + expireAt : new Date() + }; + + archiveBrew.expireAt.setMinutes(archiveBrew.expireAt.getMinutes() + HISTORY_SAVE_DELAYS[slot]); + + const key = getKeyBySlot(brew, slot); + localStorage.setItem(key, JSON.stringify(archiveBrew)); +} + + +export function historyExists(brew){ + return Object.keys(localStorage) + .some((key)=>{ + return key.startsWith(`${HISTORY_PREFIX}-${brew.shareId}`); + }); +} + +export function loadHistory(brew){ + const history = {}; + + // Load data from local storage to History object + for (let i = 1; i <= HISTORY_SLOTS; i++){ + history[i] = getVersionBySlot(brew, i); + }; + + return history; +} + +export function updateHistory(brew) { + const history = loadHistory(brew); + + // Walk each version position + for (let slot = HISTORY_SLOTS; slot > 0; slot--){ + const storedVersion = history[slot]; + + // If slot has expired, update all lower slots and break + if(new Date() >= new Date(storedVersion.expireAt)){ + for (let updateSlot = slot - 1; updateSlot>0; updateSlot--){ + // Move data from updateSlot to updateSlot + 1 + !history[updateSlot]?.noData && updateStoredBrew(history[updateSlot], updateSlot + 1); + }; + + // Update the most recent brew + updateStoredBrew(brew, 1); + + // Break out of data checks because we found an expired value + break; + } + }; +}; + +export function getHistoryItems(brew){ + const historyArray = []; + + for (let i = 1; i <= HISTORY_SLOTS; i++){ + historyArray.push(getVersionBySlot(brew, i)); + } + + return historyArray; +}; + +export function versionHistoryGarbageCollection(){ + Object.keys(localStorage) + .filter((key)=>{ + return key.startsWith(HISTORY_PREFIX); + }) + .forEach((key)=>{ + const collectAt = new Date(JSON.parse(localStorage.getItem(key)).savedAt); + collectAt.setMinutes(collectAt.getMinutes() + GARBAGE_COLLECT_DELAY); + if(new Date() > collectAt){ + localStorage.removeItem(key); + } + }); +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6dd97c4c4..16d308bf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "homebrewery", - "version": "3.14.2", + "version": "3.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "homebrewery", - "version": "3.14.2", + "version": "3.15.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -14,7 +14,7 @@ "@babel/plugin-transform-runtime": "^7.25.4", "@babel/preset-env": "^7.25.4", "@babel/preset-react": "^7.24.7", - "@googleapis/drive": "^8.13.1", + "@googleapis/drive": "^8.14.0", "body-parser": "^1.20.2", "classnames": "^2.5.1", "codemirror": "^5.65.6", @@ -23,9 +23,9 @@ "dedent-tabs": "^0.10.3", "dompurify": "^3.1.6", "expr-eval": "^2.0.2", - "express": "^4.19.2", + "express": "^4.21.0", "express-async-handler": "^1.2.0", - "express-static-gzip": "2.1.7", + "express-static-gzip": "2.1.8", "fs-extra": "11.2.0", "js-yaml": "^4.1.0", "jwt-simple": "^0.5.6", @@ -38,22 +38,22 @@ "marked-smartypants-lite": "^1.0.2", "markedLegacy": "npm:marked@^0.3.19", "moment": "^2.30.1", - "mongoose": "^8.6.0", + "mongoose": "^8.6.2", "nanoid": "3.3.4", "nconf": "^0.12.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-frame-component": "^4.1.3", - "react-router-dom": "6.26.1", + "react-router-dom": "6.26.2", "sanitize-filename": "1.6.3", "superagent": "^10.1.0", "vitreum": "git+https://git@github.com/calculuschild/vitreum.git" }, "devDependencies": { "@stylistic/stylelint-plugin": "^3.0.1", - "eslint": "^9.9.1", - "eslint-plugin-jest": "^28.8.0", - "eslint-plugin-react": "^7.35.0", + "eslint": "^9.10.0", + "eslint-plugin-jest": "^28.8.3", + "eslint-plugin-react": "^7.36.1", "globals": "^15.9.0", "jest": "^29.7.0", "jest-expect-message": "^1.1.3", @@ -2073,9 +2073,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.1.tgz", - "integrity": "sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==", + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.10.0.tgz", + "integrity": "sha512-fuXtbiP5GWIn8Fz+LWoOMVf/Jxm+aajZYkhi6CuEm4SxymFM+eUWzbO9qXT+L0iCkL5+KGYMCSGxo686H19S1g==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2090,10 +2090,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.1.0.tgz", + "integrity": "sha512-autAXT203ixhqei9xt+qkYOvY8l6LAFIdT2UXc/RPNeUVfqRF1BV94GTJyVPFKT8nFM6MyVJhjLj9E8JWvf5zQ==", + "dev": true, + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@googleapis/drive": { - "version": "8.13.1", - "resolved": "https://registry.npmjs.org/@googleapis/drive/-/drive-8.13.1.tgz", - "integrity": "sha512-ODfl4VUIKNox570DFA6AzAEHQcKI1EQs0xzzupeAIa+S/kFan85TItXU7XywK8mDORnfkgqwXQ5N/u/BFBj5lw==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@googleapis/drive/-/drive-8.14.0.tgz", + "integrity": "sha512-AOokfpP6pCdcJXWA8khaCEgbGpWYavWTdAAhL4idbbf2VCQcJ2f7vPalAYNu6a4Sfj0Ly4Ehnd1xw9J9TixB1A==", "dependencies": { "googleapis-common": "^7.0.0" }, @@ -3013,9 +3025,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.1.tgz", - "integrity": "sha512-S45oynt/WH19bHbIXjtli6QmwNYvaz+vtnubvNpNDvUOoA/OWh6j1OikIP3G+v5GHdxyC6EXoChG3HgYGEUfcg==", + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.2.tgz", + "integrity": "sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==", "engines": { "node": ">=14.0.0" } @@ -4128,10 +4140,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "license": "MIT", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -4141,7 +4152,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -5589,10 +5600,9 @@ "license": "MIT" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -5819,16 +5829,17 @@ } }, "node_modules/eslint": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.1.tgz", - "integrity": "sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==", + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.10.0.tgz", + "integrity": "sha512-Y4D0IgtBZfOcOUAIQTSXBKoNGfY0REGqHJG6+Q81vNippW5YlKjHFj4soMxamKK1NXHUWuBZTLdU3Km+L/pcHw==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.18.0", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.9.1", + "@eslint/js": "9.10.0", + "@eslint/plugin-kit": "^0.1.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", @@ -5851,7 +5862,6 @@ "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", @@ -5878,9 +5888,9 @@ } }, "node_modules/eslint-plugin-jest": { - "version": "28.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.8.0.tgz", - "integrity": "sha512-Tubj1hooFxCl52G4qQu0edzV/+EZzPUeN8p2NnW5uu4fbDs+Yo7+qDVDc4/oG3FbCqEBmu/OC3LSsyiU22oghw==", + "version": "28.8.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.8.3.tgz", + "integrity": "sha512-HIQ3t9hASLKm2IhIOqnu+ifw7uLZkIlR7RYNv7fMcEi/p0CIiJmfriStQS2LDkgtY4nyLbIZAD+JL347Yc2ETQ==", "dev": true, "dependencies": { "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5903,11 +5913,10 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.35.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.35.0.tgz", - "integrity": "sha512-v501SSMOWv8gerHkk+IIQBkcGRGrO2nfybfj5pLxuJNFTPxxA3PSryhXTK+9pNbtkggheDdsC0E9Q8CuPk6JKA==", + "version": "7.36.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.36.1.tgz", + "integrity": "sha512-/qwbqNXZoq+VP30s1d4Nc1C5GTxjJQjk4Jzs4Wq2qzxFM7dSmuG2UkIjg2USMLh3A/aVcUNrK7v0J5U1XEGGwA==", "dev": true, - "license": "MIT", "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -6306,37 +6315,36 @@ "license": "MIT" }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "license": "MIT", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -6354,12 +6362,11 @@ "license": "MIT" }, "node_modules/express-static-gzip": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/express-static-gzip/-/express-static-gzip-2.1.7.tgz", - "integrity": "sha512-QOCZUC+lhPPCjIJKpQGu1Oa61Axg9Mq09Qvit8Of7kzpMuwDeMSqjjQteQS3OVw/GkENBoSBheuQDWPlngImvw==", - "license": "MIT", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/express-static-gzip/-/express-static-gzip-2.1.8.tgz", + "integrity": "sha512-g8tiJuI9Y9Ffy59ehVXvqb0hhP83JwZiLxzanobPaMbkB5qBWA8nuVgd+rcd5qzH3GkgogTALlc0BaADYwnMbQ==", "dependencies": { - "serve-static": "^1.14.1" + "serve-static": "^1.16.2" } }, "node_modules/express/node_modules/cookie": { @@ -6575,13 +6582,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "license": "MIT", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -6596,7 +6602,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -6604,8 +6609,7 @@ "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/find-up": { "version": "5.0.0", @@ -10524,10 +10528,12 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", - "license": "MIT" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -10795,9 +10801,9 @@ } }, "node_modules/mongoose": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.6.0.tgz", - "integrity": "sha512-p6VSbYKvD4ZIabqo8C0kS5eKX1Xpji+opTAIJ9wyuPJ8Y/FblgXSMnFRXnB40bYZLKPQT089K5KU8+bqIXtFdw==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.6.2.tgz", + "integrity": "sha512-ErbDVvuUzUfyQpXvJ6sXznmZDICD8r6wIsa0VKjJtB6/LZncqwUn5Um040G1BaNo6L3Jz+xItLSwT0wZmSmUaQ==", "dependencies": { "bson": "^6.7.0", "kareem": "2.6.3", @@ -11638,10 +11644,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", - "license": "MIT" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/path-type": { "version": "4.0.0", @@ -12075,12 +12080,11 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "license": "BSD-3-Clause", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -12205,11 +12209,11 @@ "license": "MIT" }, "node_modules/react-router": { - "version": "6.26.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.1.tgz", - "integrity": "sha512-kIwJveZNwp7teQRI5QmwWo39A5bXRyqpH0COKKmPnyD2vBvDwgFXSqDUYtt1h+FEyfnE8eXr7oe0MxRzVwCcvQ==", + "version": "6.26.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz", + "integrity": "sha512-tvN1iuT03kHgOFnLPfLJ8V95eijteveqdOSk+srqfePtQvqCExB8eHOYnlilbOcyJyKnYkr1vJvf7YqotAJu1A==", "dependencies": { - "@remix-run/router": "1.19.1" + "@remix-run/router": "1.19.2" }, "engines": { "node": ">=14.0.0" @@ -12219,12 +12223,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.26.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.1.tgz", - "integrity": "sha512-veut7m41S1fLql4pLhxeSW3jlqs+4MtjRLj0xvuCEXsxusJCbs6I8yn9BxzzDX2XDgafrccY6hwjmd/bL54tFw==", + "version": "6.26.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.2.tgz", + "integrity": "sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==", "dependencies": { - "@remix-run/router": "1.19.1", - "react-router": "6.26.1" + "@remix-run/router": "1.19.2", + "react-router": "6.26.2" }, "engines": { "node": ">=14.0.0" @@ -12722,10 +12726,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "license": "MIT", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -12749,7 +12752,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -12757,25 +12759,30 @@ "node_modules/send/node_modules/debug/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } }, "node_modules/send/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "license": "MIT", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -14617,21 +14624,6 @@ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "license": "MIT" }, - "node_modules/url/node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", diff --git a/package.json b/package.json index b2ad61958..ef39fbf6f 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.14.2", + "version": "3.15.0", "engines": { "npm": "^10.2.x", "node": "^20.8.x" @@ -89,7 +89,7 @@ "@babel/plugin-transform-runtime": "^7.25.4", "@babel/preset-env": "^7.25.4", "@babel/preset-react": "^7.24.7", - "@googleapis/drive": "^8.13.1", + "@googleapis/drive": "^8.14.0", "body-parser": "^1.20.2", "classnames": "^2.5.1", "codemirror": "^5.65.6", @@ -98,9 +98,9 @@ "dedent-tabs": "^0.10.3", "dompurify": "^3.1.6", "expr-eval": "^2.0.2", - "express": "^4.19.2", + "express": "^4.21.0", "express-async-handler": "^1.2.0", - "express-static-gzip": "2.1.7", + "express-static-gzip": "2.1.8", "fs-extra": "11.2.0", "js-yaml": "^4.1.0", "jwt-simple": "^0.5.6", @@ -113,22 +113,22 @@ "marked-smartypants-lite": "^1.0.2", "markedLegacy": "npm:marked@^0.3.19", "moment": "^2.30.1", - "mongoose": "^8.6.0", + "mongoose": "^8.6.2", "nanoid": "3.3.4", "nconf": "^0.12.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-frame-component": "^4.1.3", - "react-router-dom": "6.26.1", + "react-router-dom": "6.26.2", "sanitize-filename": "1.6.3", "superagent": "^10.1.0", "vitreum": "git+https://git@github.com/calculuschild/vitreum.git" }, "devDependencies": { "@stylistic/stylelint-plugin": "^3.0.1", - "eslint": "^9.9.1", - "eslint-plugin-jest": "^28.8.0", - "eslint-plugin-react": "^7.35.0", + "eslint": "^9.10.0", + "eslint-plugin-jest": "^28.8.3", + "eslint-plugin-react": "^7.36.1", "globals": "^15.9.0", "jest": "^29.7.0", "jest-expect-message": "^1.1.3", diff --git a/server/app.js b/server/app.js index 90d14aa5b..f5864caae 100644 --- a/server/app.js +++ b/server/app.js @@ -1,533 +1,561 @@ -/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/ -// Set working directory to project root -process.chdir(`${__dirname}/..`); - -const _ = require('lodash'); -const jwt = require('jwt-simple'); -const express = require('express'); -const yaml = require('js-yaml'); -const app = express(); -const config = require('./config.js'); - -const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js'); -const GoogleActions = require('./googleActions.js'); -const serveCompressedStaticAssets = require('./static-assets.mv.js'); -const sanitizeFilename = require('sanitize-filename'); -const asyncHandler = require('express-async-handler'); -const templateFn = require('./../client/template.js'); - -const { DEFAULT_BREW } = require('./brewDefaults.js'); - -const { splitTextStyleAndMetadata } = require('../shared/helpers.js'); - - -const sanitizeBrew = (brew, accessType)=>{ - brew._id = undefined; - brew.__v = undefined; - if(accessType !== 'edit' && accessType !== 'shareAuthor') { - brew.editId = undefined; - } - return brew; -}; - -app.use('/', serveCompressedStaticAssets(`build`)); -app.use(require('./middleware/content-negotiation.js')); -app.use(require('body-parser').json({ limit: '25mb' })); -app.use(require('cookie-parser')()); -app.use(require('./forcessl.mw.js')); - -//Account Middleware -app.use((req, res, next)=>{ - if(req.cookies && req.cookies.nc_session){ - try { - req.account = jwt.decode(req.cookies.nc_session, config.get('secret')); - //console.log("Just loaded up JWT from cookie:"); - //console.log(req.account); - } catch (e){} - } - - req.config = { - google_client_id : config.get('google_client_id'), - google_client_secret : config.get('google_client_secret') - }; - return next(); -}); - -app.use(homebrewApi); -app.use(require('./admin.api.js')); - -const HomebrewModel = require('./homebrew.model.js').model; -const welcomeText = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8'); -const welcomeTextLegacy = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8'); -const migrateText = require('fs').readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8'); -const changelogText = require('fs').readFileSync('changelog.md', 'utf8'); -const faqText = require('fs').readFileSync('faq.md', 'utf8'); - -String.prototype.replaceAll = function(s, r){return this.split(s).join(r);}; - -const defaultMetaTags = { - site_name : 'The Homebrewery - Make your Homebrew content look legit!', - title : 'The Homebrewery', - description : 'A NaturalCrit Tool for creating authentic Homebrews using Markdown.', - image : `${config.get('publicUrl')}/thumbnail.png`, - type : 'website' -}; - -//Robots.txt -app.get('/robots.txt', (req, res)=>{ - return res.sendFile(`robots.txt`, { root: process.cwd() }); -}); - -//Home page -app.get('/', (req, res, next)=>{ - req.brew = { - text : welcomeText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Homepage', - description : 'Homepage' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Home page Legacy -app.get('/legacy', (req, res, next)=>{ - req.brew = { - text : welcomeTextLegacy, - renderer : 'legacy', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Homepage (Legacy)', - description : 'Homepage' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Legacy/Other Document -> v3 Migration Guide -app.get('/migrate', (req, res, next)=>{ - req.brew = { - text : migrateText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'v3 Migration Guide', - description : 'A brief guide to converting Legacy documents to the v3 renderer.' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Changelog page -app.get('/changelog', async (req, res, next)=>{ - req.brew = { - title : 'Changelog', - text : changelogText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Changelog', - description : 'Development changelog.' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//FAQ page -app.get('/faq', async (req, res, next)=>{ - req.brew = { - title : 'FAQ', - text : faqText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'FAQ', - description : 'Frequently Asked Questions' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Source page -app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{ - const { brew } = req; - - const replaceStrings = { '&': '&', '<': '<', '>': '>' }; - let text = brew.text; - for (const replaceStr in replaceStrings) { - text = text.replaceAll(replaceStr, replaceStrings[replaceStr]); - } - text = `
    ${text}
    `; - res.status(200).send(text); -}); - -//Download brew source page -app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{ - const { brew } = req; - sanitizeBrew(brew, 'share'); - const prefix = 'HB - '; - - const encodeRFC3986ValueChars = (str)=>{ - return ( - encodeURIComponent(str) - .replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;}) - ); - }; - - let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', ''); - if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; }; - res.set({ - 'Cache-Control' : 'no-cache', - 'Content-Type' : 'text/plain', - 'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt` - }); - res.status(200).send(brew.text); -}); - -//Serve brew styling -app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);}); - -//User Page -app.get('/user/:username', async (req, res, next)=>{ - const ownAccount = req.account && (req.account.username == req.params.username); - - req.ogMeta = { ...defaultMetaTags, - title : `${req.params.username}'s Collection`, - description : 'View my collection of homebrew on the Homebrewery.' - // type : could be 'profile'? - }; - - const fields = [ - 'googleId', - 'title', - 'pageCount', - 'description', - 'authors', - 'lang', - 'published', - 'views', - 'shareId', - 'editId', - 'createdAt', - 'updatedAt', - 'lastViewed', - 'thumbnail', - 'tags' - ]; - - let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields) - .catch((err)=>{ - console.log(err); - }); - - if(ownAccount && req?.account?.googleId){ - const auth = await GoogleActions.authCheck(req.account, res); - let googleBrews = await GoogleActions.listGoogleBrews(auth) - .catch((err)=>{ - console.error(err); - }); - - if(googleBrews && googleBrews.length > 0) { - for (const brew of brews.filter((brew)=>brew.googleId)) { - const match = googleBrews.findIndex((b)=>b.editId === brew.editId); - if(match !== -1) { - brew.googleId = googleBrews[match].googleId; - brew.stubbed = true; - brew.pageCount = googleBrews[match].pageCount; - brew.renderer = googleBrews[match].renderer; - brew.version = googleBrews[match].version; - brew.webViewLink = googleBrews[match].webViewLink; - googleBrews.splice(match, 1); - } - } - - googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] })); - brews = _.concat(brews, googleBrews); - } - } - - req.brews = _.map(brews, (brew)=>{ - // Clean up brew data - brew.title = brew.title?.trim(); - brew.description = brew.description?.trim(); - return sanitizeBrew(brew, ownAccount ? 'edit' : 'share'); - }); - - return next(); -}); - -//Edit Page -app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{ - req.brew = req.brew.toObject ? req.brew.toObject() : req.brew; - - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : req.brew.title || 'Untitled Brew', - description : req.brew.description || 'No description.', - image : req.brew.thumbnail || defaultMetaTags.image, - type : 'article' - }; - - sanitizeBrew(req.brew, 'edit'); - splitTextStyleAndMetadata(req.brew); - res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save. - return next(); -})); - -//New Page from ID -app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{ - sanitizeBrew(req.brew, 'share'); - splitTextStyleAndMetadata(req.brew); - const brew = { - shareId : req.brew.shareId, - title : `CLONE - ${req.brew.title}`, - text : req.brew.text, - style : req.brew.style, - renderer : req.brew.renderer, - theme : req.brew.theme, - tags : req.brew.tags, - }; - req.brew = _.defaults(brew, DEFAULT_BREW); - - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : 'New', - description : 'Start crafting your homebrew on the Homebrewery!' - }; - - return next(); -})); - -//New Page -app.get('/new', asyncHandler(async(req, res, next)=>{ - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : 'New', - description : 'Start crafting your homebrew on the Homebrewery!' - }; - - return next(); -})); - -//Share Page -app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{ - const { brew } = req; - req.ogMeta = { ...defaultMetaTags, - title : req.brew.title || 'Untitled Brew', - description : req.brew.description || 'No description.', - image : req.brew.thumbnail || defaultMetaTags.image, - type : 'article' - }; - - // increase visitor view count, do not include visits by author(s) - if(!brew.authors.includes(req.account?.username)){ - if(req.params.id.length > 12 && !brew._id) { - const googleId = brew.googleId; - const shareId = brew.shareId; - await GoogleActions.increaseView(googleId, shareId, 'share', brew) - .catch((err)=>{next(err);}); - } else { - await HomebrewModel.increaseView({ shareId: brew.shareId }); - } - }; - - brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share'); - splitTextStyleAndMetadata(req.brew); - return next(); -})); - -//Account Page -app.get('/account', asyncHandler(async (req, res, next)=>{ - const data = {}; - data.title = 'Account Information Page'; - - if(!req.account) { - res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"'); - const error = new Error('No valid account'); - error.status = 401; - error.HBErrorCode = '50'; - error.page = data.title; - return next(error); - }; - - let auth; - let googleCount = []; - if(req.account) { - if(req.account.googleId) { - try { - auth = await GoogleActions.authCheck(req.account, res, false); - } catch (e) { - auth = undefined; - console.log('Google auth check failed!'); - console.log(e); - } - if(auth.credentials.access_token) { - try { - googleCount = await GoogleActions.listGoogleBrews(auth); - } catch (e) { - googleCount = undefined; - console.log('List Google files failed!'); - console.log(e); - } - } - } - - const query = { authors: req.account.username, googleId: { $exists: false } }; - const mongoCount = await HomebrewModel.countDocuments(query) - .catch((err)=>{ - mongoCount = 0; - console.log(err); - }); - - data.accountDetails = { - username : req.account.username, - issued : req.account.issued, - googleId : Boolean(req.account.googleId), - authCheck : Boolean(req.account.googleId && auth.credentials.access_token), - mongoCount : mongoCount, - googleCount : googleCount?.length - }; - } - - req.brew = data; - - req.ogMeta = { ...defaultMetaTags, - title : `Account Page`, - description : null - }; - - return next(); -})); - -const nodeEnv = config.get('node_env'); -const isLocalEnvironment = config.get('local_environments').includes(nodeEnv); -// Local only -if(isLocalEnvironment){ - // Login - app.post('/local/login', (req, res)=>{ - const username = req.body.username; - if(!username) return; - - const payload = jwt.encode({ username: username, issued: new Date }, config.get('secret')); - return res.json(payload); - }); -} - -//Send rendered page -app.use(asyncHandler(async (req, res, next)=>{ - if (!req.route) return res.redirect('/'); // Catch-all for invalid routes - - const page = await renderPage(req, res); - if(!page) return; - res.send(page); -})); - -//Render the page -const renderPage = async (req, res)=>{ - // Create configuration object - const configuration = { - local : isLocalEnvironment, - publicUrl : config.get('publicUrl') ?? '', - environment : nodeEnv - }; - const props = { - version : require('./../package.json').version, - url : req.customUrl || req.originalUrl, - brew : req.brew, - brews : req.brews, - googleBrews : req.googleBrews, - account : req.account, - enable_v3 : config.get('enable_v3'), - enable_themes : config.get('enable_themes'), - config : configuration, - ogMeta : req.ogMeta, - userThemes : req.userThemes - }; - const title = req.brew ? req.brew.title : ''; - const page = await templateFn('homebrew', title, props) - .catch((err)=>{ - console.log(err); - }); - return page; -}; - -//v=====----- Error-Handling Middleware -----=====v// -//Format Errors as plain objects so all fields will appear in the string sent -const formatErrors = (key, value)=>{ - if(value instanceof Error) { - const error = {}; - Object.getOwnPropertyNames(value).forEach(function (key) { - error[key] = value[key]; - }); - return error; - } - return value; -}; - -const getPureError = (error)=>{ - return JSON.parse(JSON.stringify(error, formatErrors)); -}; - -app.use(async (err, req, res, next)=>{ - err.originalUrl = req.originalUrl; - console.error(err); - - if(err.originalUrl?.startsWith('/api/')) { - // console.log('API error'); - res.status(err.status || err.response?.status || 500).send(err); - return; - } - - // console.log('non-API error'); - const status = err.status || err.code || 500; - - req.ogMeta = { ...defaultMetaTags, - title : 'Error Page', - description : 'Something went wrong!' - }; - req.brew = { - ...err, - title : 'Error - Something went wrong!', - text : err.errors?.map((error)=>{return error.message;}).join('\n\n') || err.message || 'Unknown error!', - status : status, - HBErrorCode : err.HBErrorCode ?? '00', - pureError : getPureError(err) - }; - req.customUrl= '/error'; - - const page = await renderPage(req, res); - if(!page) return; - res.send(page); -}); - -app.use((req, res)=>{ - if(!res.headersSent) { - console.error('Headers have not been sent, responding with a server error.', req.url); - res.status(500).send('An error occurred and the server did not send a response. The error has been logged, please note the time this occurred and report this issue.'); - } -}); -//^=====--------------------------------------=====^// - -module.exports = { - app : app -}; +/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/ +// Set working directory to project root +process.chdir(`${__dirname}/..`); + +const _ = require('lodash'); +const jwt = require('jwt-simple'); +const express = require('express'); +const yaml = require('js-yaml'); +const app = express(); +const config = require('./config.js'); + +const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js'); +const GoogleActions = require('./googleActions.js'); +const serveCompressedStaticAssets = require('./static-assets.mv.js'); +const sanitizeFilename = require('sanitize-filename'); +const asyncHandler = require('express-async-handler'); +const templateFn = require('./../client/template.js'); + +const { DEFAULT_BREW } = require('./brewDefaults.js'); + +const { splitTextStyleAndMetadata } = require('../shared/helpers.js'); + + +const sanitizeBrew = (brew, accessType)=>{ + brew._id = undefined; + brew.__v = undefined; + if(accessType !== 'edit' && accessType !== 'shareAuthor') { + brew.editId = undefined; + } + return brew; +}; + +app.use('/', serveCompressedStaticAssets(`build`)); +app.use(require('./middleware/content-negotiation.js')); +app.use(require('body-parser').json({ limit: '25mb' })); +app.use(require('cookie-parser')()); +app.use(require('./forcessl.mw.js')); + +//Account Middleware +app.use((req, res, next)=>{ + if(req.cookies && req.cookies.nc_session){ + try { + req.account = jwt.decode(req.cookies.nc_session, config.get('secret')); + //console.log("Just loaded up JWT from cookie:"); + //console.log(req.account); + } catch (e){} + } + + req.config = { + google_client_id : config.get('google_client_id'), + google_client_secret : config.get('google_client_secret') + }; + return next(); +}); + +app.use(homebrewApi); +app.use(require('./admin.api.js')); +app.use(require('./vault.api.js')); + +const HomebrewModel = require('./homebrew.model.js').model; +const welcomeText = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8'); +const welcomeTextLegacy = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8'); +const migrateText = require('fs').readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8'); +const changelogText = require('fs').readFileSync('changelog.md', 'utf8'); +const faqText = require('fs').readFileSync('faq.md', 'utf8'); + +String.prototype.replaceAll = function(s, r){return this.split(s).join(r);}; + +const defaultMetaTags = { + site_name : 'The Homebrewery - Make your Homebrew content look legit!', + title : 'The Homebrewery', + description : 'A NaturalCrit Tool for creating authentic Homebrews using Markdown.', + image : `${config.get('publicUrl')}/thumbnail.png`, + type : 'website' +}; + +//Robots.txt +app.get('/robots.txt', (req, res)=>{ + return res.sendFile(`robots.txt`, { root: process.cwd() }); +}); + +//Home page +app.get('/', (req, res, next)=>{ + req.brew = { + text : welcomeText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Homepage', + description : 'Homepage' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); +}); + +//Home page Legacy +app.get('/legacy', (req, res, next)=>{ + req.brew = { + text : welcomeTextLegacy, + renderer : 'legacy', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Homepage (Legacy)', + description : 'Homepage' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); +}); + +//Legacy/Other Document -> v3 Migration Guide +app.get('/migrate', (req, res, next)=>{ + req.brew = { + text : migrateText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'v3 Migration Guide', + description : 'A brief guide to converting Legacy documents to the v3 renderer.' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); +}); + +//Changelog page +app.get('/changelog', async (req, res, next)=>{ + req.brew = { + title : 'Changelog', + text : changelogText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Changelog', + description : 'Development changelog.' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); +}); + +//FAQ page +app.get('/faq', async (req, res, next)=>{ + req.brew = { + title : 'FAQ', + text : faqText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'FAQ', + description : 'Frequently Asked Questions' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); +}); + +//Source page +app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{ + const { brew } = req; + + const replaceStrings = { '&': '&', '<': '<', '>': '>' }; + let text = brew.text; + for (const replaceStr in replaceStrings) { + text = text.replaceAll(replaceStr, replaceStrings[replaceStr]); + } + text = `
    ${text}
    `; + res.status(200).send(text); +}); + +//Download brew source page +app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{ + const { brew } = req; + sanitizeBrew(brew, 'share'); + const prefix = 'HB - '; + + const encodeRFC3986ValueChars = (str)=>{ + return ( + encodeURIComponent(str) + .replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;}) + ); + }; + + let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', ''); + if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; }; + res.set({ + 'Cache-Control' : 'no-cache', + 'Content-Type' : 'text/plain', + 'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt` + }); + res.status(200).send(brew.text); +}); + +//Serve brew metadata +app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{ + const { brew } = req; + sanitizeBrew(brew, 'share'); + + const fields = ['title', 'pageCount', 'description', 'authors', 'lang', + 'published', 'views', 'shareId', 'createdAt', 'updatedAt', + 'lastViewed', 'thumbnail', 'tags' + ]; + + const metadata = fields.reduce((acc, field)=>{ + if(brew[field] !== undefined) acc[field] = brew[field]; + return acc; + }, {}); + res.status(200).json(metadata); +}); + +//Serve brew styling +app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);}); + +//User Page +app.get('/user/:username', async (req, res, next)=>{ + const ownAccount = req.account && (req.account.username == req.params.username); + + req.ogMeta = { ...defaultMetaTags, + title : `${req.params.username}'s Collection`, + description : 'View my collection of homebrew on the Homebrewery.' + // type : could be 'profile'? + }; + + const fields = [ + 'googleId', + 'title', + 'pageCount', + 'description', + 'authors', + 'lang', + 'published', + 'views', + 'shareId', + 'editId', + 'createdAt', + 'updatedAt', + 'lastViewed', + 'thumbnail', + 'tags' + ]; + + let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields) + .catch((err)=>{ + console.log(err); + }); + + if(ownAccount && req?.account?.googleId){ + const auth = await GoogleActions.authCheck(req.account, res); + let googleBrews = await GoogleActions.listGoogleBrews(auth) + .catch((err)=>{ + console.error(err); + }); + + if(googleBrews && googleBrews.length > 0) { + for (const brew of brews.filter((brew)=>brew.googleId)) { + const match = googleBrews.findIndex((b)=>b.editId === brew.editId); + if(match !== -1) { + brew.googleId = googleBrews[match].googleId; + brew.stubbed = true; + brew.pageCount = googleBrews[match].pageCount; + brew.renderer = googleBrews[match].renderer; + brew.version = googleBrews[match].version; + brew.webViewLink = googleBrews[match].webViewLink; + googleBrews.splice(match, 1); + } + } + + googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] })); + brews = _.concat(brews, googleBrews); + } + } + + req.brews = _.map(brews, (brew)=>{ + // Clean up brew data + brew.title = brew.title?.trim(); + brew.description = brew.description?.trim(); + return sanitizeBrew(brew, ownAccount ? 'edit' : 'share'); + }); + + return next(); +}); + +//Edit Page +app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{ + req.brew = req.brew.toObject ? req.brew.toObject() : req.brew; + + req.userThemes = await(getUsersBrewThemes(req.account?.username)); + + req.ogMeta = { ...defaultMetaTags, + title : req.brew.title || 'Untitled Brew', + description : req.brew.description || 'No description.', + image : req.brew.thumbnail || defaultMetaTags.image, + type : 'article' + }; + + sanitizeBrew(req.brew, 'edit'); + splitTextStyleAndMetadata(req.brew); + res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save. + return next(); +})); + +//New Page from ID +app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{ + sanitizeBrew(req.brew, 'share'); + splitTextStyleAndMetadata(req.brew); + const brew = { + shareId : req.brew.shareId, + title : `CLONE - ${req.brew.title}`, + text : req.brew.text, + style : req.brew.style, + renderer : req.brew.renderer, + theme : req.brew.theme, + tags : req.brew.tags, + }; + req.brew = _.defaults(brew, DEFAULT_BREW); + + req.userThemes = await(getUsersBrewThemes(req.account?.username)); + + req.ogMeta = { ...defaultMetaTags, + title : 'New', + description : 'Start crafting your homebrew on the Homebrewery!' + }; + + return next(); +})); + +//New Page +app.get('/new', asyncHandler(async(req, res, next)=>{ + req.userThemes = await(getUsersBrewThemes(req.account?.username)); + + req.ogMeta = { ...defaultMetaTags, + title : 'New', + description : 'Start crafting your homebrew on the Homebrewery!' + }; + + return next(); +})); + +//Share Page +app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{ + const { brew } = req; + req.ogMeta = { ...defaultMetaTags, + title : req.brew.title || 'Untitled Brew', + description : req.brew.description || 'No description.', + image : req.brew.thumbnail || defaultMetaTags.image, + type : 'article' + }; + + // increase visitor view count, do not include visits by author(s) + if(!brew.authors.includes(req.account?.username)){ + if(req.params.id.length > 12 && !brew._id) { + const googleId = brew.googleId; + const shareId = brew.shareId; + await GoogleActions.increaseView(googleId, shareId, 'share', brew) + .catch((err)=>{next(err);}); + } else { + await HomebrewModel.increaseView({ shareId: brew.shareId }); + } + }; + + brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share'); + splitTextStyleAndMetadata(req.brew); + return next(); +})); + +//Account Page +app.get('/account', asyncHandler(async (req, res, next)=>{ + const data = {}; + data.title = 'Account Information Page'; + + if(!req.account) { + res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"'); + const error = new Error('No valid account'); + error.status = 401; + error.HBErrorCode = '50'; + error.page = data.title; + return next(error); + }; + + let auth; + let googleCount = []; + if(req.account) { + if(req.account.googleId) { + try { + auth = await GoogleActions.authCheck(req.account, res, false); + } catch (e) { + auth = undefined; + console.log('Google auth check failed!'); + console.log(e); + } + if(auth.credentials.access_token) { + try { + googleCount = await GoogleActions.listGoogleBrews(auth); + } catch (e) { + googleCount = undefined; + console.log('List Google files failed!'); + console.log(e); + } + } + } + + const query = { authors: req.account.username, googleId: { $exists: false } }; + const mongoCount = await HomebrewModel.countDocuments(query) + .catch((err)=>{ + mongoCount = 0; + console.log(err); + }); + + data.accountDetails = { + username : req.account.username, + issued : req.account.issued, + googleId : Boolean(req.account.googleId), + authCheck : Boolean(req.account.googleId && auth.credentials.access_token), + mongoCount : mongoCount, + googleCount : googleCount?.length + }; + } + + req.brew = data; + + req.ogMeta = { ...defaultMetaTags, + title : `Account Page`, + description : null + }; + + return next(); +})); + +const nodeEnv = config.get('node_env'); +const isLocalEnvironment = config.get('local_environments').includes(nodeEnv); +// Local only +if(isLocalEnvironment){ + // Login + app.post('/local/login', (req, res)=>{ + const username = req.body.username; + if(!username) return; + + const payload = jwt.encode({ username: username, issued: new Date }, config.get('secret')); + return res.json(payload); + }); +} + +//Vault Page +app.get('/vault', asyncHandler(async(req, res, next)=>{ + req.ogMeta = { ...defaultMetaTags, + title : 'The Vault', + description : 'Search for Brews' + }; + return next(); +})); + +//Send rendered page +app.use(asyncHandler(async (req, res, next)=>{ + if(!req.route) return res.redirect('/'); // Catch-all for invalid routes + + const page = await renderPage(req, res); + if(!page) return; + res.send(page); +})); + +//Render the page +const renderPage = async (req, res)=>{ + // Create configuration object + const configuration = { + local : isLocalEnvironment, + publicUrl : config.get('publicUrl') ?? '', + environment : nodeEnv, + history : config.get('historyConfig') ?? {} + }; + const props = { + version : require('./../package.json').version, + url : req.customUrl || req.originalUrl, + brew : req.brew, + brews : req.brews, + googleBrews : req.googleBrews, + account : req.account, + enable_v3 : config.get('enable_v3'), + enable_themes : config.get('enable_themes'), + config : configuration, + ogMeta : req.ogMeta, + userThemes : req.userThemes + }; + const title = req.brew ? req.brew.title : ''; + const page = await templateFn('homebrew', title, props) + .catch((err)=>{ + console.log(err); + }); + return page; +}; + +//v=====----- Error-Handling Middleware -----=====v// +//Format Errors as plain objects so all fields will appear in the string sent +const formatErrors = (key, value)=>{ + if(value instanceof Error) { + const error = {}; + Object.getOwnPropertyNames(value).forEach(function (key) { + error[key] = value[key]; + }); + return error; + } + return value; +}; + +const getPureError = (error)=>{ + return JSON.parse(JSON.stringify(error, formatErrors)); +}; + +app.use(async (err, req, res, next)=>{ + err.originalUrl = req.originalUrl; + console.error(err); + + if(err.originalUrl?.startsWith('/api/')) { + // console.log('API error'); + res.status(err.status || err.response?.status || 500).send(err); + return; + } + + // console.log('non-API error'); + const status = err.status || err.code || 500; + + req.ogMeta = { ...defaultMetaTags, + title : 'Error Page', + description : 'Something went wrong!' + }; + req.brew = { + ...err, + title : 'Error - Something went wrong!', + text : err.errors?.map((error)=>{return error.message;}).join('\n\n') || err.message || 'Unknown error!', + status : status, + HBErrorCode : err.HBErrorCode ?? '00', + pureError : getPureError(err) + }; + req.customUrl= '/error'; + + const page = await renderPage(req, res); + if(!page) return; + res.send(page); +}); + +app.use((req, res)=>{ + if(!res.headersSent) { + console.error('Headers have not been sent, responding with a server error.', req.url); + res.status(500).send('An error occurred and the server did not send a response. The error has been logged, please note the time this occurred and report this issue.'); + } +}); +//^=====--------------------------------------=====^// + +module.exports = { + app : app +}; diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 52fe57360..f15376af7 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -99,7 +99,7 @@ const api = { stub = stub?.toObject(); if(stub?.lock?.locked && accessType != 'edit') { - throw { HBErrorCode: '100', code: stub.lock.code, message: stub.lock.shareMessage, brewId: stub.shareId, brewTitle: stub.title }; + throw { HBErrorCode: '51', code: stub.lock.code, message: stub.lock.shareMessage, brewId: stub.shareId, brewTitle: stub.title }; } // If there is a google id, try to find the google brew diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js index d168c73fb..dd4641c09 100644 --- a/server/homebrew.api.spec.js +++ b/server/homebrew.api.spec.js @@ -309,7 +309,7 @@ describe('Tests for api', ()=>{ const req = { brew: {} }; const next = jest.fn(); - await expect(fn(req, null, next)).rejects.toEqual({ 'HBErrorCode': '100', 'brewId': '1', 'brewTitle': 'test brew', 'code': 404, 'message': 'brew locked' }); + await expect(fn(req, null, next)).rejects.toEqual({ 'HBErrorCode': '51', 'brewId': '1', 'brewTitle': 'test brew', 'code': 404, 'message': 'brew locked' }); }); }); @@ -934,7 +934,7 @@ brew`); expect(req.brew).toEqual(testBrew); expect(req.brew).toHaveProperty('style', '\nI Have a style!\n'); expect(res.status).toHaveBeenCalledWith(200); - expect(res.send).toHaveBeenCalledWith("\nI Have a style!\n"); + expect(res.send).toHaveBeenCalledWith('\nI Have a style!\n'); expect(res.set).toHaveBeenCalledWith({ 'Cache-Control' : 'no-cache', 'Content-Type' : 'text/css' diff --git a/server/vault.api.js b/server/vault.api.js new file mode 100644 index 000000000..41ceeab8e --- /dev/null +++ b/server/vault.api.js @@ -0,0 +1,102 @@ +const express = require('express'); +const asyncHandler = require('express-async-handler'); +const HomebrewModel = require('./homebrew.model.js').model; + +const router = express.Router(); + +const titleConditions = (title)=>{ + if(!title) return {}; + return { + $text : { + $search : title, + $caseSensitive : false, + }, + }; +}; + +const authorConditions = (author)=>{ + if(!author) return {}; + return { authors: author }; +}; + +const rendererConditions = (legacy, v3)=>{ + if(legacy === 'true' && v3 !== 'true') + return { renderer: 'legacy' }; + + if(v3 === 'true' && legacy !== 'true') + return { renderer: 'V3' }; + + return {}; // If all renderers selected, renderer field not needed in query for speed +}; + +const findBrews = async (req, res)=>{ + const title = req.query.title || ''; + const author = req.query.author || ''; + const page = Math.max(parseInt(req.query.page) || 1, 1); + const count = Math.max(parseInt(req.query.count) || 20, 10); + const skip = (page - 1) * count; + + const combinedQuery = { + $and : [ + { published: true }, + rendererConditions(req.query.legacy, req.query.v3), + titleConditions(title), + authorConditions(author) + ], + }; + + const projection = { + editId : 0, + googleId : 0, + text : 0, + textBin : 0, + version : 0 + }; + + await HomebrewModel.find(combinedQuery, projection) + .skip(skip) + .limit(count) + .maxTimeMS(5000) + .exec() + .then((brews)=>{ + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + const processedBrews = brews.map((brew)=>{ + brew.authors = brew.authors.map((author)=>emailRegex.test(author) ? 'hidden' : author + ); + return brew; + }); + res.json({ brews: processedBrews, page }); + }) + .catch((error)=>{ + throw { ...error, message: 'Error finding brews in Vault search', HBErrorCode: 90 }; + }); +}; + +const findTotal = async (req, res)=>{ + const title = req.query.title || ''; + const author = req.query.author || ''; + + const combinedQuery = { + $and : [ + { published: true }, + rendererConditions(req.query.legacy, req.query.v3), + titleConditions(title), + authorConditions(author) + ], + }; + + await HomebrewModel.countDocuments(combinedQuery) + .then((totalBrews)=>{ + console.log(`when returning, the total of brews is ${totalBrews} for the query ${JSON.stringify(combinedQuery)}`); + res.json({ totalBrews }); + }) + .catch((error)=>{ + throw { ...error, message: 'Error finding brews in Vault search findTotal function', HBErrorCode: 91 }; + }); +}; + +router.get('/api/vault/total', asyncHandler(findTotal)); +router.get('/api/vault', asyncHandler(findBrews)); + +module.exports = router; diff --git a/shared/naturalcrit/codeEditor/codeEditor.jsx b/shared/naturalcrit/codeEditor/codeEditor.jsx index 3186e39f1..fb69b6dcf 100644 --- a/shared/naturalcrit/codeEditor/codeEditor.jsx +++ b/shared/naturalcrit/codeEditor/codeEditor.jsx @@ -397,6 +397,11 @@ const CodeEditor = createClass({ getCursorPosition : function(){ return this.codeMirror.getCursor(); }, + getTopVisibleLine : function(){ + const rect = this.codeMirror.getWrapperElement().getBoundingClientRect(); + const topVisibleLine = this.codeMirror.lineAtHeight(rect.top, 'window'); + return topVisibleLine; + }, updateSize : function(){ this.codeMirror.refresh(); }, diff --git a/shared/naturalcrit/markdown.js b/shared/naturalcrit/markdown.js index 205063641..ef789bdd6 100644 --- a/shared/naturalcrit/markdown.js +++ b/shared/naturalcrit/markdown.js @@ -105,16 +105,16 @@ renderer.link = function (href, title, text) { // Expose `src` attribute as `--HB_src` to make the URL accessible via CSS renderer.image = function (href, title, text) { href = cleanUrl(href); - if (href === null) + if(href === null) return text; let out = `${text}"']/; diff --git a/shared/naturalcrit/splitPane/splitPane.jsx b/shared/naturalcrit/splitPane/splitPane.jsx index 55af5e386..23ae5d321 100644 --- a/shared/naturalcrit/splitPane/splitPane.jsx +++ b/shared/naturalcrit/splitPane/splitPane.jsx @@ -7,8 +7,9 @@ const SplitPane = createClass({ displayName : 'SplitPane', getDefaultProps : function() { return { - storageKey : 'naturalcrit-pane-split', - onDragFinish : function(){} //fires when dragging + storageKey : 'naturalcrit-pane-split', + onDragFinish : function(){}, //fires when dragging + showDividerButtons : true }; }, @@ -41,6 +42,10 @@ const SplitPane = createClass({ }); } window.addEventListener('resize', this.handleWindowResize); + + // This lives here instead of in the initial render because you cannot touch localStorage until the componant mounts. + const loadLiveScroll = window.localStorage.getItem('liveScroll') === 'true'; + this.setState({ liveScroll: loadLiveScroll }); }, componentWillUnmount : function() { @@ -88,6 +93,11 @@ const SplitPane = createClass({ userSetDividerPos : newSize }); }, + + liveScrollToggle : function() { + window.localStorage.setItem('liveScroll', String(!this.state.liveScroll)); + this.setState({ liveScroll: !this.state.liveScroll }); + }, /* unFocus : function() { if(document.selection){ @@ -119,13 +129,18 @@ const SplitPane = createClass({ onClick={()=>this.setState({ moveBrew: !this.state.moveBrew })} >
    +
    + +
    ; } }, renderDivider : function(){ return <> - {this.renderMoveArrows()} + {this.props.showDividerButtons && this.renderMoveArrows()}
    @@ -142,9 +157,12 @@ const SplitPane = createClass({ width={this.state.currentDividerPos} > {React.cloneElement(this.props.children[0], { - moveBrew : this.state.moveBrew, - moveSource : this.state.moveSource, - setMoveArrows : this.setMoveArrows + ...(this.props.showDividerButtons && { + moveBrew : this.state.moveBrew, + moveSource : this.state.moveSource, + liveScroll : this.state.liveScroll, + setMoveArrows : this.setMoveArrows, + }), })} {this.renderDivider()} diff --git a/shared/naturalcrit/splitPane/splitPane.less b/shared/naturalcrit/splitPane/splitPane.less index 831b5ce47..e5b3dd7f8 100644 --- a/shared/naturalcrit/splitPane/splitPane.less +++ b/shared/naturalcrit/splitPane/splitPane.less @@ -53,6 +53,15 @@ .tooltipRight('Jump to location in Preview'); top : 60px; } + &.lock{ + .tooltipRight('De-sync Editor and Preview locations.'); + top : 90px; + background: #666; + } + &.unlock{ + .tooltipRight('Sync Editor and Preview locations'); + top : 90px; + } &:hover{ background-color: #666; } diff --git a/themes/V3/5ePHB/snippets.js b/themes/V3/5ePHB/snippets.js index 4daa05c51..c3094abc4 100644 --- a/themes/V3/5ePHB/snippets.js +++ b/themes/V3/5ePHB/snippets.js @@ -27,35 +27,154 @@ module.exports = [ experimental : true, subsnippets : [ { - name : 'Table of Contents', + name : 'Generate Table of Contents', icon : 'fas fa-book', gen : TableOfContentsGen, experimental : true }, { - name : 'Include in ToC up to H3', - icon : 'fas fa-dice-three', + name : 'Table of Contents Individual Inclusion', + icon : 'fas fa-book', + gen : dedent `\n{{tocInclude# CHANGE # to your header level + }}\n`, + subsnippets : [ + { + name : 'Individual Inclusion H1', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH1 \n + }}\n`, + }, + { + name : 'Individual Inclusion H2', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH2 \n + }}\n`, + }, + { + name : 'Individual Inclusion H3', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH3 \n + }}\n`, + }, + { + name : 'Individual Inclusion H4', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH4 \n + }}\n`, + }, + { + name : 'Individual Inclusion H5', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH5 \n + }}\n`, + }, + { + name : 'Individual Inclusion H6', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH6 \n + }}\n`, + } + ] + }, + { + name : 'Table of Contents Range Inclusion', + icon : 'fas fa-book', gen : dedent `\n{{tocDepthH3 }}\n`, + subsnippets : [ + { + name : 'Include in ToC up to H3', + icon : 'fas fa-dice-three', + gen : dedent `\n{{tocDepthH3 + }}\n`, + }, + { + name : 'Include in ToC up to H4', + icon : 'fas fa-dice-four', + gen : dedent `\n{{tocDepthH4 + }}\n`, + }, + { + name : 'Include in ToC up to H5', + icon : 'fas fa-dice-five', + gen : dedent `\n{{tocDepthH5 + }}\n`, + }, + { + name : 'Include in ToC up to H6', + icon : 'fas fa-dice-six', + gen : dedent `\n{{tocDepthH6 + }}\n`, + }, + ] }, { - name : 'Include in ToC up to H4', - icon : 'fas fa-dice-four', - gen : dedent `\n{{tocDepthH4 + name : 'Table of Contents Individual Exclusion', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH1 \n }}\n`, + subsnippets : [ + { + name : 'Individual Exclusion H1', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH1 \n + }}\n`, + }, + { + name : 'Individual Exclusion H2', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH2 \n + }}\n`, + }, + { + name : 'Individual Exclusion H3', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH3 \n + }}\n`, + }, + { + name : 'Individual Exclusion H4', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH4 \n + }}\n`, + }, + { + name : 'Individual Exclusion H5', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH5 \n + }}\n`, + }, + { + name : 'Individual Exclusion H6', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH6 \n + }}\n`, + }, + ] }, + { - name : 'Include in ToC up to H5', - icon : 'fas fa-dice-five', - gen : dedent `\n{{tocDepthH5 - }}\n`, - }, - { - name : 'Include in ToC up to H6', - icon : 'fas fa-dice-six', - gen : dedent `\n{{tocDepthH6 - }}\n`, + name : 'Table of Contents Toggles', + icon : 'fas fa-book', + gen : `{{tocGlobalH4}}\n\n`, + subsnippets : [ + { + name : 'Enable H1-H4 all pages', + icon : 'fas fa-dice-four', + gen : `{{tocGlobalH4}}\n\n`, + }, + { + name : 'Enable H1-H5 all pages', + icon : 'fas fa-dice-five', + gen : `{{tocGlobalH5}}\n\n`, + }, + { + name : 'Enable H1-H6 all pages', + icon : 'fas fa-dice-six', + gen : `{{tocGlobalH6}}\n\n`, + }, + ] } ] }, @@ -94,7 +213,7 @@ module.exports = [ background-image: linear-gradient(-45deg, #322814, #998250, #322814); line-height: 1em; }\n\n` - } + }, ] }, diff --git a/themes/V3/5ePHB/snippets/tableOfContents.gen.js b/themes/V3/5ePHB/snippets/tableOfContents.gen.js index 3aea01735..44c400762 100644 --- a/themes/V3/5ePHB/snippets/tableOfContents.gen.js +++ b/themes/V3/5ePHB/snippets/tableOfContents.gen.js @@ -1,77 +1,78 @@ -const _ = require('lodash'); const dedent = require('dedent-tabs').default; -const getTOC = (pages)=>{ +// Map each actual page to its footer label, accounting for skips or numbering resets +const mapPages = (pages)=>{ + let actualPage = 0; + let mappedPage = 0; // Number displayed in footer + const pageMap = []; - const recursiveAdd = (title, page, targetDepth, child, curDepth=0)=>{ - if(curDepth > 5) return; // Something went wrong. - if(curDepth == targetDepth) { - child.push({ - title : title, - page : page, - children : [] - }); - } else { - if(child.length == 0) { - child.push({ - title : null, - page : page, - children : [] - }); + pages.forEach((page)=>{ + actualPage++; + const doSkip = page.querySelector('.skipCounting'); + const doReset = page.querySelector('.resetCounting'); + + if(doReset) + mappedPage = 1; + if(!doSkip && !doReset) + mappedPage++; + + pageMap[actualPage] = { + mappedPage : mappedPage, + showPage : !doSkip + }; + }); + return pageMap; +}; + +const getMarkdown = (headings, pageMap)=>{ + const levelPad = ['- ###', ' - ####', ' -', ' -', ' -', ' -']; + + const allMarkdown = []; + const depthChain = [0]; + + headings.forEach((heading)=>{ + const page = parseInt(heading.closest('.page').id?.replace(/^p/, '')); + const mappedPage = pageMap[page].mappedPage; + const showPage = pageMap[page].showPage; + const title = heading.textContent.trim(); + const ToCExclude = getComputedStyle(heading).getPropertyValue('--TOC'); + const depth = parseInt(heading.tagName.substring(1)); + + if(!title || !showPage || ToCExclude == 'exclude') + return; + + //If different header depth than last, remove indents until nearest higher-level header, then indent once + if(depth !== depthChain[depthChain.length -1]) { + while (depth <= depthChain[depthChain.length - 1]) { + depthChain.pop(); } - recursiveAdd(title, page, targetDepth, _.last(child).children, curDepth+1,); + depthChain.push(depth); } - }; - const res = []; + const markdown = `${levelPad[depthChain.length - 2]} [{{ ${title}}}{{ ${mappedPage}}}](#p${page})`; + allMarkdown.push(markdown); + }); + return allMarkdown.join('\n'); +}; +const getTOC = ()=>{ const iframe = document.getElementById('BrewRenderer'); const iframeDocument = iframe.contentDocument || iframe.contentWindow.document; const headings = iframeDocument.querySelectorAll('h1, h2, h3, h4, h5, h6'); - const headerDepth = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']; + const pages = iframeDocument.querySelectorAll('.page'); - _.each(headings, (heading)=>{ - const onPage = parseInt(heading.closest('.page').id?.replace(/^p/, '')); - const ToCExclude = getComputedStyle(heading).getPropertyValue('--TOC'); - - if(ToCExclude != 'exclude') { - recursiveAdd(heading.textContent.trim(), onPage, headerDepth.indexOf(heading.tagName), res); - } - }); - return res; -}; - - -const ToCIterate = (entries, curDepth=0)=>{ - const levelPad = ['- ###', ' - ####', ' - ', ' - ', ' - ', ' - ']; - const toc = []; - if(entries.title !== null){ - toc.push(`${levelPad[curDepth]} [{{ ${entries.title}}}{{ ${entries.page}}}](#p${entries.page})`); - } - if(entries.children.length) { - _.each(entries.children, (entry, idx)=>{ - const children = ToCIterate(entry, entry.title == null ? curDepth : curDepth+1); - if(children.length) { - toc.push(...children); - } - }); - } - return toc; + const pageMap = mapPages(pages); + return getMarkdown(headings, pageMap); }; module.exports = function(props){ - const pages = props.brew.text.split('\\page'); - const TOC = getTOC(pages); - const markdown = _.reduce(TOC, (r, g1, idx1)=>{ - r.push(ToCIterate(g1).join('\n')); - return r; - }, []).join('\n'); + const TOC = getTOC(); return dedent` {{toc,wide # Contents - ${markdown} + ${TOC} }} \n`; -}; +}; \ No newline at end of file diff --git a/themes/V3/5ePHB/style.less b/themes/V3/5ePHB/style.less index ddffbec2f..5a2b5cf3f 100644 --- a/themes/V3/5ePHB/style.less +++ b/themes/V3/5ePHB/style.less @@ -11,6 +11,7 @@ --HB_Color_CaptionText : #766649; // Brown --HB_Color_WatercolorStain : #BBAD82; // Light brown --HB_Color_Footnotes : #C9AD6A; // Gold + --TOC : 'include'; } .useSansSerif() { @@ -797,7 +798,7 @@ // *****************************/ // Default Exclusions -// Anything not exlcuded is included, default Headers are H1, H2, and H3. +// Anything not excluded is included, default Headers are H1, H2, and H3. h4, h5, h6, @@ -808,12 +809,23 @@ h6, .noToC, .toc { --TOC: exclude; } -.tocDepthH2 :is(h1, h2) {--TOC: include; } -.tocDepthH3 :is(h1, h2, h3) {--TOC: include; } -.tocDepthH4 :is(h1, h2, h3, h4) {--TOC: include; } -.tocDepthH5 :is(h1, h2, h3, h4, h5) {--TOC: include; } -.tocDepthH6 :is(h1, h2, h3, h4, h5, h6) {--TOC: include; } +// Brew level default inclusion changes. +// These add Headers 'back' to inclusion. +.pages:has(.tocGlobalH4) { + h4 {--TOC: include; } +} + +.pages:has(.tocGlobalH5) { + h4, h5 {--TOC: include; } +} + +.pages:has(.tocGlobalH6) { + h4, h5, h6 {--TOC: include; } +} + +// Block level inclusion changes +// These include either a single (include) or a range (depth) .tocIncludeH1 h1 {--TOC: include; } .tocIncludeH2 h2 {--TOC: include; } .tocIncludeH3 h3 {--TOC: include; } @@ -821,6 +833,21 @@ h6, .tocIncludeH5 h5 {--TOC: include; } .tocIncludeH6 h6 {--TOC: include; } +.tocDepthH2 :is(h1, h2) {--TOC: include; } +.tocDepthH3 :is(h1, h2, h3) {--TOC: include; } +.tocDepthH4 :is(h1, h2, h3, h4) {--TOC: include; } +.tocDepthH5 :is(h1, h2, h3, h4, h5) {--TOC: include; } +.tocDepthH6 :is(h1, h2, h3, h4, h5, h6) {--TOC: include; } + +// Block level exclusion changes +// These exclude a single block level +.tocExcludeH1 h1 {--TOC: exclude; } +.tocExcludeH2 h2 {--TOC: exclude; } +.tocExcludeH3 h3 {--TOC: exclude; } +.tocExcludeH4 h4 {--TOC: exclude; } +.tocExcludeH5 h5 {--TOC: exclude; } +.tocExcludeH6 h6 {--TOC: exclude; } + .page:has(.partCover) { --TOC: exclude; & h1 { diff --git a/themes/V3/Blank/snippets.js b/themes/V3/Blank/snippets.js index 8a6a60091..99f90f79e 100644 --- a/themes/V3/Blank/snippets.js +++ b/themes/V3/Blank/snippets.js @@ -23,14 +23,30 @@ module.exports = [ gen : '\n\\page\n' }, { - name : 'Page Number', - icon : 'fas fa-bookmark', - gen : '{{pageNumber 1}}\n' - }, - { - name : 'Auto-incrementing Page Number', - icon : 'fas fa-sort-numeric-down', - gen : '{{pageNumber,auto}}\n' + name : 'Page Numbering', + icon : 'fas fa-bookmark', + subsnippets : [ + { + name : 'Page Number', + icon : 'fas fa-bookmark', + gen : '{{pageNumber 1}}\n' + }, + { + name : 'Auto-incrementing Page Number', + icon : 'fas fa-sort-numeric-down', + gen : '{{pageNumber,auto}}\n' + }, + { + name : 'Skip Page Number Increment this Page', + icon : 'fas fa-xmark', + gen : '{{skipCounting}}\n' + }, + { + name : 'Restart Numbering', + icon : 'fas fa-arrow-rotate-left', + gen : '{{resetCounting}}\n' + }, + ] }, { name : 'Footer', diff --git a/themes/V3/Blank/style.less b/themes/V3/Blank/style.less index 0f3766342..f03ca90d3 100644 --- a/themes/V3/Blank/style.less +++ b/themes/V3/Blank/style.less @@ -12,7 +12,7 @@ } @page { margin : 0; } -body { counter-reset : page-numbers; } +body { counter-reset : page-numbers 0; } * { -webkit-print-color-adjust : exact; } //***************************** @@ -51,7 +51,6 @@ body { counter-reset : page-numbers; } height : 279.4mm; padding : 1.4cm 1.9cm 1.7cm; overflow : hidden; - counter-increment : page-numbers; background-color : var(--HB_Color_Background); text-rendering : optimizeLegibility; contain : size; @@ -494,4 +493,13 @@ body { counter-reset : page-numbers; } &:nth-child(even) { .pageNumber { left : 30px; } } -} + + .resetCounting { + counter-set : page-numbers 1; + } + + &:not(:has(.skipCounting)) { + counter-increment : page-numbers; + } + +} \ No newline at end of file