package com.mousetech.gourmetj; import java.io.Serializable; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; import javax.annotation.PostConstruct; import javax.faces.event.AjaxBehaviorEvent; import javax.faces.model.DataModel; import javax.faces.model.ListDataModel; import javax.faces.view.ViewScoped; import javax.inject.Inject; import javax.inject.Named; import javax.servlet.http.Part; import org.apache.commons.lang3.StringUtils; import org.primefaces.event.FileUploadEvent; import org.primefaces.model.UploadedFile; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.mousetech.gourmetj.IngredientUI; import com.mousetech.gourmetj.UserSession; import com.mousetech.gourmetj.persistence.model.Category; import com.mousetech.gourmetj.persistence.model.Ingredient; import com.mousetech.gourmetj.persistence.model.Recipe; import com.mousetech.gourmetj.persistence.model.Shopcat; import com.mousetech.gourmetj.persistence.service.RecipeService; import com.mousetech.gourmetj.springweb.PictureController; import com.mousetech.gourmetj.utils.IngredientDigester; /** * Backing bean for display/edit recipe detail * * @author timh */ @Named @ViewScoped public class RecipeDetailBean implements Serializable { private static final long serialVersionUID = 1L; /* Logger */ private static final Logger log = LoggerFactory.getLogger(RecipeDetailBean.class); // Split lines at 2 or more spaces OR at line terminators. private static final String RE_INGSPLIT = "\\s\\s+|\\r?+\\n"; /** * Default Constructor. */ public RecipeDetailBean() { log.debug("Constructing RecipeDetail " + this); } /** * Persistency service for Recipes, Shopcats and Categories */ @Inject transient RecipeService recipeService; public void setRecipeService(RecipeService service) { log.debug("INJECT RECIPESERVICE===" + service); this.recipeService = service; } private String instructions = null; @Inject UserSession userSession; /** * @return the userSession */ public UserSession getUserSession() { return userSession; } /** * @param userSession the userSession to set */ public void setUserSession(UserSession userSession) { this.userSession = userSession; } /** * @param instructions the instructions to set * @see #getInstructions() */ public void setInstructions(String instructions) { this.instructions = instructions; } /** * @param ingredients the ingredients to set */ public void setIngredients( DataModel ingredients) { this.ingredients = ingredients; } // ** private String ingredientText = ""; /** * @return the ingredientText */ public String getIngredientText() { return ingredientText; } /** * @param ingredientText the ingredientText to set */ public void setIngredientText(String ingredientText) { this.ingredientText = ingredientText; } // ================================== private Recipe recipe = new Recipe(); public Recipe getRecipe() { if (this.recipe.getId() == null) { Long recipeId = userSession.getLastEdit(); if (recipeId != null) { loadRecipe(recipeId); } } return this.recipe; } /** * @param recipe the recipe to set Required by JSF 2.2, but * DON'T USE! */ public void setRecipe(Recipe recipe) { this.recipe = recipe; } public String getTitle() { return "ATITLE"; } /**/ transient DataModel ingredients; /**/ private String category = ""; /** * @return the category as a comma-separated list. * @see #stringifyCategories(Recipe) */ public String getCategory() { return category; } /** * @param category the category (list) to set * @see #getCategory() */ public void setCategory(String category) { this.category = category; this.setDirty(); } private boolean dirty; private String modifications; /** * @return the dirty */ public boolean isDirty() { return dirty; } /** * @param dirty the dirty to set */ public void setDirty(boolean dirty) { this.dirty = dirty; } public void setDirty() { setDirty(true); } /** * @return the ingredients */ public DataModel getIngredients() { if (ingredients == null) { ingredients = new ListDataModel<>(); ingredients .setWrappedData(new ArrayList(1)); } return ingredients; } /** * After construction and injection, we obtain the recipe ID * passed to us, if any and load the recipe. It's also stored * in @see UserSession for the detail editor and * * @see PictureController. */ @PostConstruct private void init() { this.recipe = userSession.getRecipe(); /** * For "create new, this recipe is a blank constructed * and passed from main page. For Detail display, it's * null and ID come in via Flash scope. For Detail Edit, * it's passed from detail display or "create new". */ if (this.recipe == null) { Long rid = // (Long) JSFUtils.getFlash("recipeID"); userSession.getLastEdit(); if (rid != null) { this.recipe = loadRecipe(rid); } else { // alternative (and probably dead) version of // "new // recipe". this.recipe = new Recipe(); return; } } userSession.setRecipe(this.recipe); getIngredients().setWrappedData( buildIngredientFacade(recipe.getIngredientHash())); stringifyCategories(recipe); this.shop = this.getUserSession().getShoppingList() .contains(recipe); log.info("Set recipe: " + this.recipe); } /** * Load recipe from database. @see RecipeDetailBean#init() * * @param recipeId ID of recipe to load * @return loaded recipe or null, if not found. */ private Recipe loadRecipe(Long recipeId) { Recipe recipe = recipeService.findDetails(recipeId); if (recipe == null) { return null; } stringifyCategories(recipe); return recipe; } /** * Categories are a Set attached to recipe. Build a * displayable comma-separated list of them. * * @param recipe Recipe to get categories from. * * @see #getCategory() */ private void stringifyCategories(Recipe recipe) { Set cList = recipe.getCategories(); StringBuffer sb = new StringBuffer(35); boolean first = true; for (Category cat : cList) { if (first) { first = false; } else { sb.append(", "); } sb.append(cat.getCategory()); } this.category = sb.toString(); } /** * Wrap a list of Ingredients in a decorative facade for * better displayability. * * Ingredient group is actually part of Ingredient. Add dummy * "ingredients" to make them separate lines. * * @param ingredients * @return Wrapped list */ private List buildIngredientFacade( List ingredients) { final List list = new ArrayList<>(ingredients.size()); String currentIngGroup = null; for (Ingredient ing : ingredients) { String ingGroup = ing.getInggroup(); if (ingGroup != null && ingGroup.isBlank()) { ingGroup = null; } if (!Objects.equals(ingGroup, currentIngGroup)) { Ingredient dummy = new Ingredient(); IngredientUI groupIng = new IngredientUI(dummy); groupIng.setItem(ingGroup); groupIng.setIngGroup(true); list.add(groupIng); currentIngGroup = ingGroup; } IngredientUI ingUi = new IngredientUI(ing); list.add(ingUi); // Shopcat is an eager fetch on Ingredient Shopcat shopCat = ing.getShopCat(); if (shopCat != null) { ingUi.setShopCat(shopCat); } } return list; } /** * @return the instructions (cached) */ public String getInstructions() { if (instructions == null) { instructions = formatInstructions( getRecipe().getInstructions()); } return instructions; } /** * @return the instructions (cached) */ public String getModifications() { if (this.modifications == null) { this.modifications = formatInstructions( getRecipe().getModifications()); } return this.modifications; } /** * Convert instruction plain-text to HTML form * * @param instructions * @return */ private String formatInstructions(String instructions) { if (instructions == null) { return ""; } String s = instructions.replace("\r\n", "

") .replace("\n\n", "

"); s = s.replace("\n", "
"); return s; } /** * Action for "Add Ingredient" button * * @return null - stay on page. The Ingredient list table * will update. */ public String doAddIngredient() { this.addIngredientList(this.getIngredientText()); setIngredientText(""); // clear for next entry updateSelectionStatus(); return null; } // ===== /** * Handle entry of ingredient line(s) into text control on * the input form. * * Note: In the original Tobago port of this app, the input * was an inputText. In PrimeFaces, this did not preserve * line separation characters, so an inputTextArea was used * instead. * * @param event Unused */ public void ajaxAddIngredient(AjaxBehaviorEvent event) { doAddIngredient(); } // === /** * Listen to the SELECT checkboxes on Ingredients and update * the action button statuses. * * @param event notused */ public void ajaxSelectionListener(AjaxBehaviorEvent event) { updateSelectionStatus(); } /** * Manage the ability buttons based on selections. */ private void updateSelectionStatus() { List ingList = getWrappedIngredients(); final int ingCount = ingList.size(); boolean moveUpable = true; boolean moveDownable = true; boolean selectable = false; for (int i = 0; i < ingCount; i++) { boolean selected = ingList.get(i).isSelected(); if ((i == 0) && selected) { moveUpable = false; } if ((i == (ingCount - 1)) && selected) { moveDownable = false; } selectable |= selected; } this.setMoveUpAble(moveUpable && selectable); this.setMoveDownAble(moveDownable && selectable); this.setSelectable(selectable); auditRows(ingList); } // --- public void setMoveUpAble(boolean moveUpable) { this.moveUpable = moveUpable; } public void setMoveDownAble(boolean moveDownable) { this.moveDownable = moveDownable; } public void setSelectable(boolean selectable) { this.selectable = selectable; } public boolean isMoveUpAble() { return this.moveUpable; } public boolean isMoveDownAble() { return this.moveDownable; } public boolean isSelectable() { return this.selectable; } // --- public void ajaxMoveUp() { if (!isMoveUpAble()) { JSFUtils.addErrorMessage("Cannot move up."); return; } final List rows = getWrappedIngredients(); final int ingSize = rows.size(); for (int i = 1; i < ingSize; i++) { IngredientUI r = rows.get(i); if (r.isSelected()) { // swap with preceding row. rows.remove(i); rows.add(i - 1, r); } } this.setDirty(); updateSelectionStatus(); } private void auditRows(List rows) { // log.info("=== AUDIT ROWS ==="); // for ( IngredientUI row : rows ) { // log.info((row.isSelected() ? "[X]" : "[ ]" ) +" ROW="+row); // } // log.info("=== DONE ==="); } /** * Move selected rows down. * * @param eventUnused */ public void ajaxMoveDown() { if (!isMoveDownAble()) { JSFUtils.addErrorMessage("Cannot move down."); return; } final List rows = getWrappedIngredients(); final int ingSize = rows.size() - 1; for (int i = ingSize; i > 0; i--) { IngredientUI r = rows.get(i - 1); if (r.isSelected()) { // swap with following row. rows.remove(i - 1); rows.add(i, r); } } updateSelectionStatus(); } public void ajaxDeleteItems() { final List rows = getWrappedIngredients(); List selectedRows = new ArrayList(); for (IngredientUI row : rows) { if (row.isSelected()) { this.dirty = true; // Delete row from list. selectedRows.add(row); } } // 2nd stage to avoid ConcurrentModificationException for (IngredientUI row : selectedRows) { rows.remove(row); } updateSelectionStatus(); } // ===== @Inject EditShopcatBean editShopcatBean; private boolean moveUpable; private boolean moveDownable; private boolean selectable; /** * Invoked when the "E"(dit" button for Ingkey shopping * category has been clicked. * * @param item The item whose ingredient key will have its * shopping category edited. Resets the dialog * backing bean internal state. */ public void ajaxEditShopcat(IngredientUI item) { editShopcatBean.beginEdit(item.getIngkey(), item.getShopCat()); } /** * On "OK" for edit shopcat where shopcat has changed, update * the shopcat Entity and the ingredients. */ public void doUpdateShopcat() { final String key = editShopcatBean.getIngkey(); if (StringUtils.isBlank(key)) { return; // Do not set category if no ingKey } final String catname = editShopcatBean.getShopcatName(); Shopcat sc = this.recipeService .findShopcatForIngredientKey(key); if (sc == null) { sc = new Shopcat(); sc.setIngkey(key); } else { if (StringUtils.equals(sc.getShopcategory(), catname)) { return; // No change } } sc.setShopcategory(catname); /* * Because the database does not have a UNIQUE constraint * on ingkeys, we must delete old shopcat(s) for this key * before adding (updating) the new shopcat. */ this.recipeService.deleteShopcatByIngKey(key); if (!StringUtils.isBlank(catname)) { this.recipeService.saveShopcat(sc); } updateDisplayedShopcats(key, sc); } /** * When Shopcat name changes, update the Ingredients. In * detailEdit, an AJAX "render" will then update the display. * In recipeDetails, nothing actually shows. */ private void updateDisplayedShopcats(String key, Shopcat sc) { List ingList = this.getWrappedIngredients(); for (IngredientUI ingUI : ingList) { if (key.equals(ingUI.getIngkey())) { ingUI.setShopCat(sc); } } } /** * Bulk add for ingredients. Looks for long input and if * found, tries to split it up and add as multiple * ingredients. * * @param ingredientText2 * @see #addIngredient(String) */ private void addIngredientList(String ingredientTextLines) { if (ingredientTextLines.length() < 40) { addIngredient(ingredientTextLines); return; } // Otherwise, try for split. String[] lineArray = ingredientTextLines.split(RE_INGSPLIT); for (String line : lineArray) { if (line.isBlank()) { continue; // actually should discard any above // this } if (line.toLowerCase().contains("ingredients")) { continue; // actually should discard any above // this } addIngredient(line); } updateSelectionStatus(); } /** * Add ingredient text line to recipe * * @param ingredientText * @see #doAddIngredient(), bulk loader * @see #addIngredientList(String) */ public void addIngredient(String ingredientText) { log.info("Ingredient line: \"" + ingredientText + "\""); Ingredient ing = IngredientDigester.digest(ingredientText); // get ing list size, set ing position, append List ingredients = getWrappedIngredients(); int lsize = ingredients.size(); ing.setPosition(lsize + 1); ingredients.add(new IngredientUI(ing)); } // === /** * Convenience method to get ingredientUI list without * constant whining about unchecked casting. * * @return */ @SuppressWarnings("unchecked") private List getWrappedIngredients() { return (List) this.getIngredients() .getWrappedData(); } private boolean shop = false; public boolean isShop() { return shop; } /** * Add/remove recipe to shopping list (toggle) */ public void doShop() { shop = !shop; List shoppingList = userSession.getShoppingList(); if (shop) { shoppingList.add(recipe); } else { shoppingList.remove(recipe); } } /** * Save the recipe. * * @return Main page if OK, re-display with error if cannot * save ingredients. */ public String doSave() { // if ! isDirty()... // Update recipe object based on UI: if (!saveIngredients()) { JSFUtils .addErrorMessage("Could not save Ingredients"); return null; } if (recipeService.save(this.getRecipe())) { userSession.setRecipe(null); return "recipeDetails"; } else { JSFUtils.addErrorMessage("Save recipe failed"); return null; } } /** * Delete the recipe. This will cause ingredients to be * deleted by cascade. * * @return Main page */ public String doDelete() { recipeService.delete(this.recipe); this.userSession.setLastEdit(null); // Don't point to me! return "/main.jsf"; } /** * Update ingredients and shopcat from UI model * * @return false if a category has no ingredient key to link * it. */ private boolean saveIngredients() { if (!updateIngredientList()) { return false; } updateRecipeCategories(recipe, category); // Rebuild ingredients list with groups applied updateRecipeGroups(getWrappedIngredients()); return true; } private boolean updateIngredientList() { List saveIng = getWrappedIngredients(); List iList = recipe.getIngredientHash(); iList.clear(); for (IngredientUI iui : saveIng) { if (iui.isIngGroup()) { // Ing group is an attribute of ingredients. continue; } Ingredient ing = iui.getIngredient(); ing.setRecipe(recipe); if (!updateShopcat(iui)) { // Obsolete??? log.info("Shopcat has not been updated" + iui); return false; } iList.add(ing); } return true; } /** * Apply ingredient group IDs (optional) to individual * ingredients. * * @param wrappedIngredients The wrapped ingredient facade. */ private void updateRecipeGroups( List wrappedIngredients) { String ingGroup = null; for (IngredientUI ingUI : wrappedIngredients) { if (ingUI.isIngGroup()) { ingGroup = ingUI.getItem(); } else { ingUI.getIngredient().setInggroup(ingGroup); } } } /** * Update shopcat for Ingredient. * * @param ing Ingredient to update * @return true if update succeeded. */ private boolean updateShopcat(IngredientUI ingUI) { final Ingredient ing = ingUI.getIngredient(); final String ingKey = ing.getIngkey(); if ((ingKey == null) || (ingKey.isBlank())) { ing.setIngkey(null); ing.setShopCat(null); return true; } Shopcat scat = recipeService .findShopcatForIngredientKey(ingKey); if (scat == null) { log.debug( "No Shopping Category is defined for Ingredient Key " + ingKey); // return false; } ing.setShopCat(scat); return true; } /** * Parse out the comma-separated category text control and * post the results as children of the recipe * * @param recipe2 * @param category2 */ private void updateRecipeCategories(Recipe recipe2, String category2) { final Set oldList = recipe2.getCategories(); List newList = new ArrayList(); String[] cats = this.category.split(","); for (String s : cats) { s = s.trim(); if (!s.isEmpty()) { Category newCat = new Category(); newCat.setRecipe(recipe2); newCat.setCategory(s); newList.add(newCat); } } for (Category cat : newList) { // For existing Categories, use existing ID. Category ocat = searchCategory(oldList, cat); if (ocat != null) { cat.setId(ocat.getId()); } } recipe.setCategories(new HashSet(newList)); } private Category searchCategory(Set oldList, Category cat) { String catName = cat.getCategory(); for (Category c : oldList) { if (catName.equals(c.getCategory())) { return c; } } return null; } /** * Partial input sent via AJAX when suggestion box is keyed. * called each time a character is entered or removed, * subject (I presume) to the minimum character limit. * * Use this to assemble the suggestion list. */ private String cuisinePartial = ""; /** * @return the cuisinePartial */ public String getCuisinePartial() { return cuisinePartial; } public List cuisineSuggestions(String query) { if (!query.equals(cuisinePartial)) { setCuisinePartial(query); } return getCuisineList(); } /** * Set query for eligibility, force list to rebuild. * * @param cuisinePartial the cuisinePartial to set */ public void setCuisinePartial(String cuisinePartial) { this.cuisinePartial = cuisinePartial; // trigger construction of new list. this.cuisineList = null; } private List masterCuisineList = null; private List cuisineList = null; /** * Load the Cuisine list, which is assembled by scanning * cuisine fields from all recipes in the database at the * time we are called. */ private List loadCuisineList() { List cuisines = recipeService.findCuisines(); return cuisines; } /** * @return the master cuisineList */ public List getMasterCuisineList() { if (this.masterCuisineList == null) { this.masterCuisineList = loadCuisineList(); } return this.masterCuisineList; } /** * @return the cuisineList built by matching the master list * against the partial cuisine names. */ public List getCuisineList() { if (this.cuisineList == null) { this.cuisineList = buildCuisineList(); } return this.cuisineList; } /** * Using the cuisinePartial, build a subset of the master * cuisine list. * * @return */ private List buildCuisineList() { List list = new ArrayList(); String partial = this.cuisinePartial; // Handle cases where we aren't set up, or the partial // input is blank. if (partial == null) { return list; } partial = partial.trim(); if (partial.isEmpty()) { return list; } List masterList = this.getMasterCuisineList(); for (String s : masterList) { if (s.contains(this.cuisinePartial)) { list.add(s); } } return list; } /** * @param cuisineList the cuisineList to set */ public void setCuisineList(List cuisineList) { this.cuisineList = cuisineList; } // *** // Shopcat for IngredientUI private List shopcatList; public List shopcatList(String query) { if (shopcatList == null) { shopcatList = recipeService.findShoppingCategories(); } return shopcatList; } public void ajaxUpdateShopcat(IngredientUI item) { log.warn("SHOPCAT2 "); updateShopcat(item); } // *** public String editDescription() { this.setDetailTab(0); return "detailEdit"; } public String editIngredients() { this.setDetailTab(1); return "detailEdit"; } public String editInstructions() { this.setDetailTab(2); return "detailEdit"; } public String editNotes() { this.setDetailTab(3); return "detailEdit"; } private void setDetailTab(int i) { this.userSession.setDetailTab(i); } // *** // *** Category suggestions private String catToAdd = ""; private List suggestCategory = null; /** * @return the catToAdd */ public String getCatToAdd() { return catToAdd; } /** * @param catToAdd the catToAdd to set */ public void setCatToAdd(String catToAdd) { this.catToAdd = catToAdd; } /** * @return the suggestCategory List */ public List getSuggestCategory() { if (suggestCategory == null) { suggestCategory = loadCategories(); } return suggestCategory; } private List loadCategories() { List catList = this.recipeService.findCategories(); return catList; } /** * @param suggestCategory the suggestCategory to set */ public void setSuggestCategory( List suggestCategory) { this.suggestCategory = suggestCategory; } public void ajaxSuggestCategory(AjaxBehaviorEvent event) { if (!this.category.isBlank()) { this.category += ", "; } this.category += catToAdd; catToAdd = ""; } public void getAjaxSuggestCategory() { if (!this.category.isBlank()) { this.category += ", "; } this.category += catToAdd; catToAdd = ""; } // *** Part imageFile = null; /** * @return the imageFile set by the image upload control */ public Part getImageFile() { return imageFile; } /** * @param imageFile the imageFile to set */ public void setImageFile(Part imageFile) { this.imageFile = imageFile; } /** * Load/replace images. Computes thumbnail. * * @param event PrimeFaces file upload event object */ public void ajaxUploadImage(FileUploadEvent event) { UploadedFile foo = event.getFile(); PictureController.importImage(recipe, foo.getContents()); } /** * Remove images from recipe * * @param event Notused */ public void ajaxDeleteImage(AjaxBehaviorEvent event) { this.recipe.setImage(null); this.recipe.setThumb(null); } /** * Return marker for image. Unlike normal JSF, I don't care * if it gets multiple times and returns different values. * * @return "random" string */ public String getCurrentTime() { long now = new java.util.Date().getTime(); return String.valueOf(now); } // *** Add Group private String newGroupName; /** * @return the newGroupName */ public String getNewGroupName() { return newGroupName; } /** * @param newGroupName the newGroupName to set */ public void setNewGroupName(String newGroupName) { this.newGroupName = newGroupName; } /** * Add new group to bottom of model as AJAX operation. * * @return null */ public void doAddGroup() { IngredientUI iui = new IngredientUI(null); iui.setIngGroup(true); iui.setItem(this.getNewGroupName()); List ingUIList = this.getWrappedIngredients(); ingUIList.add(iui); this.setNewGroupName(""); // Clear for next time! } }