0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-07 20:42:44 +00:00
Change local version history to use Indexed DB
This commit is contained in:
Trevor Buckner
2024-10-10 15:38:52 -04:00
committed by GitHub
6 changed files with 106 additions and 73 deletions

View File

@@ -1,11 +1,11 @@
/*eslint max-lines: ["warn", {"max": 250, "skipBlankLines": true, "skipComments": true}]*/ /*eslint max-lines: ["warn", {"max": 350, "skipBlankLines": true, "skipComments": true}]*/
require('./snippetbar.less'); require('./snippetbar.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
const cx = require('classnames'); const cx = require('classnames');
import { getHistoryItems, historyExists } from '../../utils/versionHistory.js'; import { loadHistory } from '../../utils/versionHistory.js';
//Import all themes //Import all themes
const ThemeSnippets = {}; const ThemeSnippets = {};
@@ -50,30 +50,47 @@ const Snippetbar = createClass({
renderer : this.props.renderer, renderer : this.props.renderer,
themeSelector : false, themeSelector : false,
snippets : [], snippets : [],
historyExists : false showHistory : false,
historyExists : false,
historyItems : []
}; };
}, },
componentDidMount : async function() { componentDidMount : async function(prevState) {
const snippets = this.compileSnippets(); const snippets = this.compileSnippets();
this.setState({ this.setState({
snippets : snippets snippets : snippets
}); });
}, },
componentDidUpdate : async function(prevProps) { componentDidUpdate : async function(prevProps, prevState) {
if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) { if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) {
this.setState({ this.setState({
snippets : this.compileSnippets() snippets : this.compileSnippets()
}); });
}; };
if(historyExists(this.props.brew) != this.state.historyExists){ // Update history list if it has changed
this.setState({ const checkHistoryItems = await loadHistory(this.props.brew);
historyExists : !this.state.historyExists
});
};
// If all items have the noData property, there is no saved data
const checkHistoryExists = !checkHistoryItems.every((historyItem)=>{
return historyItem?.noData;
});
if(prevState.historyExists != checkHistoryExists){
this.setState({
historyExists : checkHistoryExists
});
}
// If any history items have changed, update the list
if(checkHistoryExists && checkHistoryItems.some((historyItem, index)=>{
return index >= prevState.historyItems.length || !_.isEqual(historyItem, prevState.historyItems[index]);
})){
this.setState({
historyItems : checkHistoryItems
});
}
}, },
mergeCustomizer : function(oldValue, newValue, key) { mergeCustomizer : function(oldValue, newValue, key) {
@@ -151,12 +168,18 @@ const Snippetbar = createClass({
return this.props.updateBrew(item); return this.props.updateBrew(item);
}, },
toggleHistoryMenu : function(){
this.setState({
showHistory : !this.state.showHistory
});
},
renderHistoryItems : function() { renderHistoryItems : function() {
const historyItems = getHistoryItems(this.props.brew); if(!this.state.historyExists) return;
return <div className='dropdown'> return <div className='dropdown'>
{_.map(historyItems, (item, index)=>{ {_.map(this.state.historyItems, (item, index)=>{
if(!item.savedAt) return; if(item.noData || !item.savedAt) return;
const saveTime = new Date(item.savedAt); const saveTime = new Date(item.savedAt);
const diffMs = new Date() - saveTime; const diffMs = new Date() - saveTime;
@@ -197,9 +220,10 @@ const Snippetbar = createClass({
} }
return <div className='editors'> return <div className='editors'>
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`} > <div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
onClick={this.toggleHistoryMenu} >
<i className='fas fa-clock-rotate-left' /> <i className='fas fa-clock-rotate-left' />
{this.state.historyExists && this.renderHistoryItems() } { this.state.showHistory && this.renderHistoryItems() }
</div> </div>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`} <div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
onClick={this.props.undo} > onClick={this.props.undo} >

View File

@@ -228,8 +228,8 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text) htmlErrors : Markdown.validate(prevState.brew.text)
})); }));
updateHistory(this.state.brew); await updateHistory(this.state.brew);
versionHistoryGarbageCollection(); await versionHistoryGarbageCollection();
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);

View File

@@ -1,8 +1,10 @@
import * as IDB from 'idb-keyval/dist/index.js';
export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY'; export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY';
export const HISTORY_SLOTS = 5; export const HISTORY_SLOTS = 5;
// History values in minutes // History values in minutes
const DEFAULT_HISTORY_SAVE_DELAYS = { const HISTORY_SAVE_DELAYS = {
'0' : 0, '0' : 0,
'1' : 2, '1' : 2,
'2' : 10, '2' : 10,
@@ -10,29 +12,30 @@ const DEFAULT_HISTORY_SAVE_DELAYS = {
'4' : 12 * 60, '4' : 12 * 60,
'5' : 2 * 24 * 60 '5' : 2 * 24 * 60
}; };
// const HISTORY_SAVE_DELAYS = {
// '0' : 0,
// '1' : 1,
// '2' : 2,
// '3' : 3,
// '4' : 4,
// '5' : 5
// };
const DEFAULT_GARBAGE_COLLECT_DELAY = 28 * 24 * 60; const HB_DB = 'HOMEBREWERY-DB';
const HB_STORE = 'HISTORY';
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;
const GARBAGE_COLLECT_DELAY = 28 * 24 * 60;
// const GARBAGE_COLLECT_DELAY = 10;
function getKeyBySlot(brew, slot){ function getKeyBySlot(brew, slot){
// Return a string representing the key for this brew and history slot
return `${HISTORY_PREFIX}-${brew.shareId}-${slot}`; return `${HISTORY_PREFIX}-${brew.shareId}-${slot}`;
}; };
function getVersionBySlot(brew, slot){ function parseBrewForStorage(brew, slot = 0) {
// Read stored brew data // Strip out unneeded object properties
// - If it exists, parse data to object // Returns an array of [ key, brew ]
// - 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 = { const archiveBrew = {
title : brew.title, title : brew.title,
text : brew.text, text : brew.text,
@@ -46,44 +49,55 @@ function updateStoredBrew(brew, slot = 0) {
archiveBrew.expireAt.setMinutes(archiveBrew.expireAt.getMinutes() + HISTORY_SAVE_DELAYS[slot]); archiveBrew.expireAt.setMinutes(archiveBrew.expireAt.getMinutes() + HISTORY_SAVE_DELAYS[slot]);
const key = getKeyBySlot(brew, slot); const key = getKeyBySlot(brew, slot);
localStorage.setItem(key, JSON.stringify(archiveBrew));
return [key, archiveBrew];
} }
// Create a custom IDB store
export function historyExists(brew){ async function createHBStore(){
return Object.keys(localStorage) return await IDB.createStore(HB_DB, HB_STORE);
.some((key)=>{
return key.startsWith(`${HISTORY_PREFIX}-${brew.shareId}`);
});
} }
export function loadHistory(brew){ export async function loadHistory(brew){
const history = {}; const DEFAULT_HISTORY_ITEM = { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true };
// Load data from local storage to History object const historyKeys = [];
// Create array of all history keys
for (let i = 1; i <= HISTORY_SLOTS; i++){ for (let i = 1; i <= HISTORY_SLOTS; i++){
history[i] = getVersionBySlot(brew, i); historyKeys.push(getKeyBySlot(brew, i));
}; };
return history; // Load all keys from IDB at once
const dataArray = await IDB.getMany(historyKeys, await createHBStore());
return dataArray.map((data)=>{ return data ?? DEFAULT_HISTORY_ITEM; });
} }
export function updateHistory(brew) { export async function updateHistory(brew) {
const history = loadHistory(brew); const history = await loadHistory(brew);
// Walk each version position // Walk each version position
for (let slot = HISTORY_SLOTS; slot > 0; slot--){ for (let slot = HISTORY_SLOTS - 1; slot >= 0; slot--){
const storedVersion = history[slot]; const storedVersion = history[slot];
// If slot has expired, update all lower slots and break // If slot has expired, update all lower slots and break
if(new Date() >= new Date(storedVersion.expireAt)){ if(new Date() >= new Date(storedVersion.expireAt)){
for (let updateSlot = slot - 1; updateSlot>0; updateSlot--){
// Create array of arrays : [ [key1, value1], [key2, value2], ..., [keyN, valueN] ]
// to pass to IDB.setMany
const historyUpdate = [];
for (let updateSlot = slot; updateSlot > 0; updateSlot--){
// Move data from updateSlot to updateSlot + 1 // Move data from updateSlot to updateSlot + 1
!history[updateSlot]?.noData && updateStoredBrew(history[updateSlot], updateSlot + 1); if(!history[updateSlot - 1]?.noData) {
historyUpdate.push(parseBrewForStorage(history[updateSlot - 1], updateSlot + 1));
}
}; };
// Update the most recent brew // Update the most recent brew
updateStoredBrew(brew, 1); historyUpdate.push(parseBrewForStorage(brew, 1));
await IDB.setMany(historyUpdate, await createHBStore());
// Break out of data checks because we found an expired value // Break out of data checks because we found an expired value
break; break;
@@ -91,26 +105,15 @@ export function updateHistory(brew) {
}; };
}; };
export function getHistoryItems(brew){ export async function versionHistoryGarbageCollection(){
const historyArray = [];
for (let i = 1; i <= HISTORY_SLOTS; i++){ const entries = await IDB.entries(await createHBStore());
historyArray.push(getVersionBySlot(brew, i));
}
return historyArray; for (const [key, value] of entries){
}; const expireAt = new Date(value.savedAt);
expireAt.setMinutes(expireAt.getMinutes() + GARBAGE_COLLECT_DELAY);
export function versionHistoryGarbageCollection(){ if(new Date() > expireAt){
Object.keys(localStorage) await IDB.del(key, await createHBStore());
.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);
}
});
}; };

6
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
"express-static-gzip": "2.1.8", "express-static-gzip": "2.1.8",
"fs-extra": "11.2.0", "fs-extra": "11.2.0",
"idb-keyval": "^6.2.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jwt-simple": "^0.5.6", "jwt-simple": "^0.5.6",
"less": "^3.13.1", "less": "^3.13.1",
@@ -7363,6 +7364,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/idb-keyval": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
"integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",

View File

@@ -103,6 +103,7 @@
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
"express-static-gzip": "2.1.8", "express-static-gzip": "2.1.8",
"fs-extra": "11.2.0", "fs-extra": "11.2.0",
"idb-keyval": "^6.2.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jwt-simple": "^0.5.6", "jwt-simple": "^0.5.6",
"less": "^3.13.1", "less": "^3.13.1",

View File

@@ -481,8 +481,7 @@ const renderPage = async (req, res)=>{
const configuration = { const configuration = {
local : isLocalEnvironment, local : isLocalEnvironment,
publicUrl : config.get('publicUrl') ?? '', publicUrl : config.get('publicUrl') ?? '',
environment : nodeEnv, environment : nodeEnv
history : config.get('historyConfig') ?? {}
}; };
const props = { const props = {
version : require('./../package.json').version, version : require('./../package.json').version,