Compare commits

...

3 Commits

  1. 14
      README.md
  2. 2
      application.properties
  3. 6
      pom.xml
  4. 93
      src/main/java/com/mousetech/gourmetj/AdminMainBean.java
  5. 51
      src/main/java/com/mousetech/gourmetj/AppBean.java
  6. 127
      src/main/java/com/mousetech/gourmetj/CookieBean.java
  7. 46
      src/main/java/com/mousetech/gourmetj/JSFUtils.java
  8. 35
      src/main/java/com/mousetech/gourmetj/UserSession.java
  9. 45
      src/main/resources/META-INF/resources/WEB-INF/layout/layout.xhtml
  10. 13
      src/main/resources/META-INF/resources/detailEdit.xhtml
  11. 8
      src/main/resources/META-INF/resources/main.xhtml
  12. 6
      src/main/resources/META-INF/resources/shoppingList.xhtml
  13. 7
      src/main/resources/application.yml

@ -1,4 +1,4 @@
# Gourmet Recipe Manager - Spring Boot # Gourmet Recipe Manager - Spring Boot - Version 2
This is a port of Thomas Hinkle (thinkle) Gourmet Recipe Manager. This is a port of Thomas Hinkle (thinkle) Gourmet Recipe Manager.
@ -85,4 +85,14 @@ employed when you run this app on your local desktop.
### Improved graphics support ### Improved graphics support
A lot of recipe websites publish images in webp form. Support A lot of recipe websites publish images in webp form. Support
for webp has now been added. for webp has now been added.
### Better session management
JSF tends to depend on session-scope context. Sessions, however
time out and this has been an annoyance when a recipe is being
displayed. To minimize this, better timeout mechanisms have been
installed and the recipe browser keeps last-search and search-type
values in long-lived cookies on the client. The server will read
and cache them, but if the server times out, it will automatically
re-read the cookies on the next request.

@ -22,3 +22,5 @@ spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
# My special properties # My special properties
gourmet.password.file=${user.home}/.gourmetpw gourmet.password.file=${user.home}/.gourmetpw
# This will override aplication.yml
#server.servlet.context-parameters.primefaces.THEME=le-frog

@ -78,6 +78,12 @@
<artifactId>all-themes</artifactId> <artifactId>all-themes</artifactId>
<version>1.0.10</version> <version>1.0.10</version>
</dependency> </dependency>
<dependency>
<!-- Primefaces theme won't work without this! -->
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<scope>runtime</scope>
</dependency>
<!-- <dependency> <!-- <dependency>
<groupId>javax.enterprise</groupId> <groupId>javax.enterprise</groupId>
<artifactId>cdi-api</artifactId> <artifactId>cdi-api</artifactId>

@ -1,6 +1,7 @@
package com.mousetech.gourmetj; package com.mousetech.gourmetj;
import java.io.Serializable; import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.faces.event.AjaxBehaviorEvent; import jakarta.faces.event.AjaxBehaviorEvent;
@ -14,7 +15,9 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import com.mousetech.gourmetj.persistence.model.Recipe; import com.mousetech.gourmetj.persistence.model.Recipe;
import com.mousetech.gourmetj.persistence.service.RecipeService; import com.mousetech.gourmetj.persistence.service.RecipeService;
@ -44,8 +47,11 @@ public class AdminMainBean implements Serializable {
private static final Logger log = private static final Logger log =
LoggerFactory.getLogger(AdminMainBean.class); LoggerFactory.getLogger(AdminMainBean.class);
/** Cookie delimiter */
private static final String CKDLM = ",";
/** /**
* Persistency service for Recipes * Persistency service for Recipes.
*/ */
@Inject @Inject
@ -56,6 +62,24 @@ public class AdminMainBean implements Serializable {
this.recipeService = service; this.recipeService = service;
} }
// **
@Inject
private CookieBean cookieBean;
/**
* @return the cookieBean
*/
public CookieBean getCookieBean() {
return cookieBean;
}
/**
* @param cookieBean the cookieBean to set
*/
public void setCookieBean(CookieBean cookieBean) {
this.cookieBean = cookieBean;
}
// ** // **
@Inject @Inject
private UserSession userSession; private UserSession userSession;
@ -81,10 +105,10 @@ public class AdminMainBean implements Serializable {
* @return the searchText * @return the searchText
*/ */
public String getSearchText() { public String getSearchText() {
if (this.searchResults == null) { // if (this.searchResults == null) {
// Fake around broken @PostConstruct // this.setSearchText(cookieBean.getSearchText());
this.setSearchText(userSession.getLastSearch()); // }
} this.searchText = cookieBean.getSearchText();
return searchText; return searchText;
} }
@ -93,7 +117,7 @@ public class AdminMainBean implements Serializable {
*/ */
public void setSearchText(String searchText) { public void setSearchText(String searchText) {
this.searchText = searchText; this.searchText = searchText;
userSession.setLastSearch(searchText); cookieBean.setSearchText(searchText);
} }
private List<String> suggestionList = null; private List<String> suggestionList = null;
@ -104,7 +128,7 @@ public class AdminMainBean implements Serializable {
public List<String> searchSuggestionList(String query) { public List<String> searchSuggestionList(String query) {
if (suggestionList == null) { if (suggestionList == null) {
switch (this.userSession.getSearchType()) { switch (searchtypeEnum()) {
case rst_BY_CATEGORY: case rst_BY_CATEGORY:
suggestionList = recipeService.findCategories(); suggestionList = recipeService.findCategories();
break; break;
@ -118,6 +142,16 @@ public class AdminMainBean implements Serializable {
return suggestionList; return suggestionList;
} }
private RecipeSearchType searchtypeEnum() {
int stn = cookieBean.getSearchType();
return searchtypeEnum(stn);
}
private RecipeSearchType searchtypeEnum(int stn) {
RecipeSearchType st = RecipeSearchType.values()[stn];
return st;
}
/**/ /**/
transient DataModel<Recipe> searchResults; transient DataModel<Recipe> searchResults;
@ -127,7 +161,7 @@ public class AdminMainBean implements Serializable {
public DataModel<Recipe> getSearchResults() { public DataModel<Recipe> getSearchResults() {
if (searchResults == null) { if (searchResults == null) {
searchResults = new ListDataModel<Recipe>(); searchResults = new ListDataModel<Recipe>();
init(); // @PostConstruct is broken init(); // @PostConstruct is broken TODO: fixed??
} }
return searchResults; return searchResults;
} }
@ -147,7 +181,7 @@ public class AdminMainBean implements Serializable {
@PostConstruct @PostConstruct
void init() { void init() {
log.debug("Initializing AdminMainBean " + this); log.debug("Initializing AdminMainBean " + this);
this.setSearchText(userSession.getLastSearch()); this.setSearchText(cookieBean.getSearchText());
// Clean up from any previous operations. // Clean up from any previous operations.
this.userSession.setRecipe(null); this.userSession.setRecipe(null);
doFind(); doFind();
@ -177,9 +211,20 @@ public class AdminMainBean implements Serializable {
*/ */
public String doFind() { public String doFind() {
List<Recipe> recipes = null; List<Recipe> recipes = null;
if ( searchText == null ) {
setSearchText("");
}
searchText = searchText.trim(); searchText = searchText.trim();
switch (this.getUserSession().getSearchType()) { // Persist current settings
try {
cookieBean.saveCookies();
} catch (UnsupportedEncodingException e) {
// Something is really wrong if we can't create UTF-8!
log.error("Unable to save cookies!", e);
}
RecipeSearchType st = searchtypeEnum();
switch (st) {
case rst_BY_NAME: case rst_BY_NAME:
recipes = recipeService.findByTitle(searchText); recipes = recipeService.findByTitle(searchText);
break; break;
@ -197,12 +242,11 @@ public class AdminMainBean implements Serializable {
break; break;
default: default:
log.error("Invalid recipe search type: " log.error("Invalid recipe search type: "
+ this.getUserSession().getSearchType()); + st);
break; break;
} }
getSearchResults().setWrappedData(recipes); getSearchResults().setWrappedData(recipes);
this.userSession.setLastSearch(this.getSearchText());
return null; // Stay on page return null; // Stay on page
} }
@ -243,27 +287,4 @@ public class AdminMainBean implements Serializable {
// items. // items.
return "recipeDetails?faces-redirect=true"; return "recipeDetails?faces-redirect=true";
} }
/**
* Get printable preptime. Database version is in seconds.
*
* @deprecated User {@link UserSession#formatTime(Long)}
*
* @return Formatted time. Called from EL on main page.
*/
public String formatPreptime(int timesec) {
StringBuffer sb = new StringBuffer(20);
int preptime = timesec / 60;
if (preptime > 60) {
int hours = preptime / 60;
sb.append(hours);
sb.append(" h. ");
preptime %= 60;
}
if (preptime > 0) {
sb.append(preptime);
sb.append(" min.");
}
return sb.toString();
}
} }

@ -0,0 +1,51 @@
package com.mousetech.gourmetj;
import java.util.ArrayList;
import java.util.List;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.faces.model.SelectItem;
import jakarta.inject.Named;
/**
* Appplication-scope data (mostly constants)
*
* @author timh
* @since Feb 1, 2024
*/
@Named
@ApplicationScoped
public class AppBean {
public AppBean() {
// TODO Auto-generated constructor stub
}
private List<SelectItem> searchTypeList;
/**
* @return the searchTypeList
* @see RecipeSearchType
* Used by main.xhtml
*/
public List<SelectItem> getSearchTypeList() {
if (searchTypeList == null) {
searchTypeList = loadSearchTypeList();
}
return searchTypeList;
}
private List<SelectItem> loadSearchTypeList() {
List<SelectItem> list = new ArrayList<SelectItem>(5);
list.add(new SelectItem(RecipeSearchType.rst_BY_NAME.ordinal(),
"Title"));
list.add(new SelectItem(RecipeSearchType.rst_BY_CATEGORY.ordinal(),
"Category"));
list.add(new SelectItem(RecipeSearchType.rst_BY_CUISINE.ordinal(),
"Cuisine"));
list.add(
new SelectItem(RecipeSearchType.rst_BY_INGREDIENT.ordinal(),
"Ingredient"));
return list;
}
}

@ -0,0 +1,127 @@
/**
* Copyright (C) 2024, Tim Holloway
*
* Manages app data persisted client-side in cookies.
*
* Date written: Jan 31, 2024
* Author: Tim Holloway <timh@mousetech.com>
*/
package com.mousetech.gourmetj;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.annotation.PostConstruct;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Named;
/**
* Caching object for cookie data persistence.
*
* @author timh
* @since Jan 31, 2024
*/
@Named
@ViewScoped
public class CookieBean {
private static final String KEY_DISPLAY_ROWS = "displayRows";
private static final String KEY_SEARCH_TYPE = "searchType";
private static final String KEY_SEARCH_FOR = "searchFor";
/* Logger */
private static final Logger log =
LoggerFactory.getLogger(CookieBean.class);
private Map<String, String> cookieMap;
/**
* Constructor.
*/
public CookieBean() {
}
@PostConstruct
public void init() {
this.cookieMap = JSFUtils.getCookies();
}
/**
* Persist us to client cookie storage
*
* @throws UnsupportedEncodingException (which should never
* happen)
*/
public void saveCookies()
throws UnsupportedEncodingException {
final Map<String, Object> properties = new HashMap<>();
properties.put("maxAge", 31536000);
properties.put("path", "/");
properties.put("SameSite", "Strict");
for (Entry<String, String> e : cookieMap.entrySet()) {
JSFUtils.outputCookie(e.getKey(), e.getValue(),
properties);
}
}
/**
* Get Cookie value by name
*
* @param name Name of the cookie
* @return Value stored in the cookie
*/
public String getCookieValue(String name) {
return cookieMap.get(name);
}
public void setCookieValue(String name, String value) {
cookieMap.put(name, value);
}
// ************************
// App-specific properties
// ************************
public String getSearchText() {
return cookieMap.get(KEY_SEARCH_FOR);
}
public void setSearchText(String value) {
cookieMap.put(KEY_SEARCH_FOR, value);
}
// **
public Integer getSearchType() {
if (!cookieMap.containsKey(KEY_SEARCH_TYPE)) {
cookieMap.put(KEY_SEARCH_TYPE, "0");
}
String st = cookieMap.get(KEY_SEARCH_TYPE);
return Integer.valueOf(String.valueOf(st));
}
public void setSearchType(Integer value) {
cookieMap.put(KEY_SEARCH_TYPE, String.valueOf(value));
}
// **
public Integer getDisplayListSize() {
if (!cookieMap.containsKey(KEY_DISPLAY_ROWS)) {
cookieMap.put(KEY_DISPLAY_ROWS, "30");
}
String st = cookieMap.get(KEY_DISPLAY_ROWS);
return Integer.valueOf(String.valueOf(st));
}
public void setDisplayListSize(Integer value) {
cookieMap.put(KEY_DISPLAY_ROWS, String.valueOf(value));
}
}

@ -1,11 +1,17 @@
package com.mousetech.gourmetj; package com.mousetech.gourmetj;
import java.io.InputStream; import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import jakarta.faces.application.FacesMessage; import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.ExternalContext; import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext; import jakarta.faces.context.FacesContext;
import jakarta.faces.context.Flash; import jakarta.faces.context.Flash;
import jakarta.servlet.http.Cookie;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -55,6 +61,14 @@ public class JSFUtils {
message); message);
} }
public static void addWarningMessage(String msgString) {
FacesMessage message = new FacesMessage(
FacesMessage.SEVERITY_WARN, "WARNING", msgString);
FacesContext.getCurrentInstance().addMessage(null,
message);
}
/** /**
* Post an error-level message to the FacesContext where the * Post an error-level message to the FacesContext where the
* &lt;h:messages&gt; tag can display it. * &lt;h:messages&gt; tag can display it.
@ -103,4 +117,36 @@ public class JSFUtils {
public static void putFlash(String key, Object value) { public static void putFlash(String key, Object value) {
flashScope().put(key, value); flashScope().put(key, value);
} }
//***********
//* COOKIE!!!
//***********
/**
* Get cookie values.
*/
public static Map<String, String> getCookies(){
Map<String, Object> m0 = getExternalContext().getRequestCookieMap();
Map<String, String>m1 = new HashMap<String, String>();
m1 = m0.entrySet()
.stream()
.collect(Collectors.toMap(
e -> e.getKey(),
e -> ((Cookie)e.getValue()).getValue()));
return m1;
}
/**
* Set a cookie value in Response.
* @param name Cookie name
* @param value Cookie value
* @param properties Cookie property Map (timeout, <i>etc.</i>)
* @throws UnsupportedEncodingException
*/
public static void outputCookie(String name,
String value, Map<String, Object> properties) throws UnsupportedEncodingException {
getExternalContext().addResponseCookie(name,
URLEncoder.encode(value, "UTF-8"),
properties);
}
} }

@ -129,32 +129,6 @@ public class UserSession implements Serializable {
this.searchType = searchType; this.searchType = searchType;
} }
private List<SelectItem> searchTypeList;
/**
* @return the searchTypeList
*/
public List<SelectItem> getSearchTypeList() {
if (searchTypeList == null) {
searchTypeList = loadSearchTypeList();
}
return searchTypeList;
}
private List<SelectItem> loadSearchTypeList() {
List<SelectItem> list = new ArrayList<SelectItem>(5);
list.add(new SelectItem(RecipeSearchType.rst_BY_NAME,
"Title"));
list.add(new SelectItem(RecipeSearchType.rst_BY_CATEGORY,
"Category"));
list.add(new SelectItem(RecipeSearchType.rst_BY_CUISINE,
"Cuisine"));
list.add(
new SelectItem(RecipeSearchType.rst_BY_INGREDIENT,
"Ingredient"));
return list;
}
// ==== // ====
public String formatCategories(Recipe r) { public String formatCategories(Recipe r) {
@ -190,16 +164,17 @@ public class UserSession implements Serializable {
private List<Recipe> shoppingList = new ArrayList<Recipe>(); private List<Recipe> shoppingList = new ArrayList<Recipe>();
/** /**
* @return the sessionTimeoutInterval * @return the sessionTimeoutInterval, msec
*/ */
public long getSessionTimeoutInterval() { public long getSessionTimeoutInterval() {
return sessionTimeoutInterval; return 5000L; //sessionTimeoutInterval;
} }
public void sessionIdleListener() { public void sessionIdleListener() {
log.warn("Session Idle Listener fired."); log.warn("Session Idle Listener fired.");
PrimeFaces.current() JSFUtils.addWarningMessage("Timeout approaching. Save your work!");
.executeScript("sessionExpiredConfirmation.show()"); // PrimeFaces.current()
// .executeScript("sessionExpiredConfirmation.show()");
} }
public String logoutAction() { public String logoutAction() {

@ -6,10 +6,9 @@
xmlns:ui="http://java.sun.com/jsf/facelets" xmlns:ui="http://java.sun.com/jsf/facelets"
xmlns:p="http://primefaces.org/ui" xmlns:p="http://primefaces.org/ui"
> >
<head></head> <h:head></h:head>
<body> <h:body>
<ui:composition> <ui:composition>
<f:view>
<h:head> <h:head>
<title><ui:insert name="title">Gourmet Recipe Manager (web version)</ui:insert></title> <title><ui:insert name="title">Gourmet Recipe Manager (web version)</ui:insert></title>
<link rel="icon" type="image/vnd.microsoft.icon" <link rel="icon" type="image/vnd.microsoft.icon"
@ -29,50 +28,14 @@
</ui:insert> </ui:insert>
<!-- --> <!-- -->
<div id="footer"> <div id="footer">
(C) 2021 Tim Holloway, Licensed under the <a (C) 2021, 2024 Tim Holloway, Licensed under the <a
href="http://www.apache.org/licenses/LICENSE-2.0" href="http://www.apache.org/licenses/LICENSE-2.0"
>Apache License, Version 2.0</a>. >Apache License, Version 2.0</a>.
<p>Based on Gourmet Recipe Manager by T. <p>Based on Gourmet Recipe Manager by T.
Hinkle</p> Hinkle</p>
</div> </div>
<!-- --> <!-- -->
<h:form id="frmTimeout">
<p:idleMonitor
timeout="#{userSession.sessionTimeoutInterval}"
onidle="PF('dlgSessionExpired').show()"
>
<p:ajax event="idle"
listener="#{userSession.sessionIdleListener}"
/>
</p:idleMonitor>
<p:confirmDialog closable="false"
id="sessionExpiredDlg"
message="Your session has expired."
header="#{msgs['confirmDialog.initiatingDestroyProcess.label']}"
severity="alert"
widgetVar="dlgSessionExpired"
style="z-index: 25000"
>
<p:commandButton id="cmdExpiredOK"
value="OK" action="/main.jsf"
oncomplete="PF('dlgSessionExpired').hide()"
/>
</p:confirmDialog>
</h:form>
<!-- -->
<h:form id="frmOpErr">
<p:confirmDialog
message="Session may have expired."
header="Error"
severity="alert" widgetVar="opError"
>
<p:commandButton value="OK"
oncomplete="PF('opError').hide()"
/>
</p:confirmDialog>
</h:form>
</h:body> </h:body>
</f:view>
</ui:composition> </ui:composition>
</body> </h:body>
</html> </html>

@ -395,6 +395,19 @@
/> />
</h:form> </h:form>
</p:panel> </p:panel>
<!-- -->
<p:growl id="growl" showDetail="true" />
<h:form id="frmTimeout">
<p:idleMonitor
timeout="#{userSession.sessionTimeoutInterval}"
>
<p:ajax id="ajaxIdle" event="idle"
listener="#{userSession.sessionIdleListener}"
update="growl"
/>
</p:idleMonitor>
</h:form>
<!-- -->
<p:dialog id="addGroupDlg" widgetVar="addGroupDlg"> <p:dialog id="addGroupDlg" widgetVar="addGroupDlg">
<h:form id="frmAddGroup"> <h:form id="frmAddGroup">
<p:panelGrid columns="1"> <p:panelGrid columns="1">

@ -28,10 +28,10 @@
/> />
<p:outputLabel for="@next" value="Search for " /> <p:outputLabel for="@next" value="Search for " />
<p:selectOneMenu id="ctlSearchType" <p:selectOneMenu id="ctlSearchType"
value="#{userSession.searchType}" value="#{cookieBean.searchType}"
> >
<f:selectItems <f:selectItems
value="#{userSession.searchTypeList}" value="#{appBean.searchTypeList}"
/> />
<p:ajax <p:ajax
listener="#{adminMainBean.resetSuggestions}" listener="#{adminMainBean.resetSuggestions}"
@ -45,12 +45,12 @@
<p:commandButton value="New Recipe" <p:commandButton value="New Recipe"
action="#{adminMainBean.doNewRecipe}" action="#{adminMainBean.doNewRecipe}"
/> />
<p:commandButton value="More..." <p:commandButton value="Shopping..."
action="#{adminMainBean.doMore}" action="#{adminMainBean.doMore}"
/> />
<h:outputText id="slistSSize" <h:outputText id="slistSSize"
style="margin-left: 2em" style="margin-left: 2em"
value="#{userSession.shoppingList.size()}" value="#{cookieBean.displayListSize}"
/> />
<h:outputLabel for="slistSize" <h:outputLabel for="slistSize"
value=" Recipes in Shopping List" value=" Recipes in Shopping List"

@ -6,7 +6,7 @@
xmlns:p="http://primefaces.org/ui" xmlns:p="http://primefaces.org/ui"
xmlns:c="http://xmlns.jcp.org/jstl" xmlns:c="http://xmlns.jcp.org/jstl"
> >
<!-- Tabbed page for the Mainpage "More..." button --> <!-- Tabbed page for the Mainpage "Shopping..." button -->
<ui:define name="title">Gourmet Recipe Manager - Shopping</ui:define> <ui:define name="title">Gourmet Recipe Manager - Shopping</ui:define>
<ui:define name="content"> <ui:define name="content">
<style> <style>
@ -34,6 +34,7 @@
} }
</style> </style>
This is the list of recipe items you've selected to shop for.
<h:messages /> <h:messages />
<p:tabView id="tabGroupClient" orientation="left" <p:tabView id="tabGroupClient" orientation="left"
dynamic="true" dynamic="true"
@ -95,13 +96,11 @@
value="Ingredients" value="Ingredients"
/> />
</f:facet> </f:facet>
<p:headerRow>
<p:column colspan="4"> <p:column colspan="4">
<h:outputText <h:outputText
value="#{item.shopCat}" value="#{item.shopCat}"
/> />
</p:column> </p:column>
</p:headerRow>
<p:column label="Amt" <p:column label="Amt"
style="width: 3em; text-align: right" style="width: 3em; text-align: right"
> >
@ -150,6 +149,7 @@
</p:tab> </p:tab>
<!-- --> <!-- -->
<p:tab id="tabPantry" title="Pantry"> <p:tab id="tabPantry" title="Pantry">
<h:outputText value="Stuff already in the pantry." />
<h:outputText value="For future implementation" /> <h:outputText value="For future implementation" />
</p:tab> </p:tab>
<!-- --> <!-- -->

@ -26,6 +26,11 @@ server:
servlet: servlet:
session: session:
timeout: '30m' timeout: '30m'
# Theme here pverrides jinfaces theme
# context-parameters:
# primefaces:
# THEME: vela
gourmet: gourmet:
password: password:
@ -33,4 +38,4 @@ gourmet:
joinfaces: joinfaces:
primefaces: primefaces:
theme: saga theme: casablanca

Loading…
Cancel
Save