Merge branch 'main' into 'feature/server_search_bar'

# Conflicts:
#   server/src/main/java/server/api/RecipeController.java
This commit is contained in:
Rithvik Sriram 2026-01-09 13:49:51 +01:00
commit caa78515cf
14 changed files with 131 additions and 33 deletions

View file

@ -118,6 +118,18 @@
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId>
<version>2.0.17</version> <!-- use matching version for your SLF4J -->
</dependency>
<!-- Logback Classic logger implementation -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.20</version> <!-- or latest compatible version -->
</dependency>
</dependencies>
<build>

View file

@ -15,8 +15,17 @@
*/
package client;
import org.slf4j.bridge.SLF4JBridgeHandler;
public class Main {
static {
// Choose SLF4J Logger (Spring Boot) for JavaFX.
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
}
public static void main(String[] args){
UI.launch(UI.class, args);
}
}

View file

@ -6,6 +6,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import client.exception.InvalidModificationException;
@ -45,7 +46,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
private final WebSocketUtils webSocketUtils;
private final LocaleManager localeManager;
private final WebSocketDataService<Long, Recipe> dataService;
private final Logger logger = Logger.getLogger(FoodpalApplicationCtrl.class.getName());
@FXML
private RecipeDetailCtrl recipeDetailController;
@ -93,24 +94,31 @@ public class FoodpalApplicationCtrl implements LocaleAware {
this.configService = configService;
this.dataService = recipeDataService;
setupDataService();
logger.info("WebSocket processor initialized.");
initializeWebSocket();
logger.info("WebSocket connection handler initialized.");
logger.info("Main application controller initialized.");
}
private void setupDataService() {
dataService.setMessageParser((msg) -> switch (msg) {
case CreateRecipeMessage _ -> (m) -> {
CreateRecipeMessage crm = (CreateRecipeMessage) m;
logger.info("Server informs us of creation of recipe: " + crm.getRecipe());
return handleCreateRecipeMessage(crm);
};
case UpdateRecipeMessage _ -> (m) -> {
UpdateRecipeMessage urm = (UpdateRecipeMessage) m;
logger.info("Server informs us of update for recipe: " + urm.getRecipe());
return handleUpdateRecipeMessage(urm);
};
case DeleteRecipeMessage _ -> (m) -> {
DeleteRecipeMessage drm = (DeleteRecipeMessage) m;
logger.info("Server informs us of the deletion of recipe with ID: " + drm.getRecipeId());
return handleDeleteRecipeMessage(drm);
};
case FavouriteRecipeMessage _ -> (m) -> {
FavouriteRecipeMessage frm = (FavouriteRecipeMessage) m;
logger.info("Server informs us of a favourite recipe being deleted: " + frm.getRecipeId());
return handleFavouriteRecipeMessage(frm);
};
default -> throw new IllegalStateException("Unexpected value: " + msg);
@ -185,7 +193,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
this.recipeList.getItems().setAll(recipes);
System.out.println("Search returned " + recipes.size() + " recipes.");
logger.info("Search returned " + recipes.size() + " recipes.");
// Restore selection, if possible
if (newIndex != -1) {
@ -263,7 +271,9 @@ public class FoodpalApplicationCtrl implements LocaleAware {
recipes = server.getRecipesFiltered(searchBarController.getFilter());
} catch (IOException | InterruptedException e) {
recipes = Collections.emptyList();
System.err.println("Failed to load recipes: " + e.getMessage());
String msg = "Failed to load recipes: " + e.getMessage();
logger.severe(msg);
printError(msg);
}
allRecipes = new ArrayList<>(recipes);
@ -295,7 +305,10 @@ public class FoodpalApplicationCtrl implements LocaleAware {
dataService.add(newRecipe.getId(), recipe -> {
this.recipeList.getSelectionModel().select(recipe);
openSelectedRecipe();
Platform.runLater(() -> this.recipeDetailController.editRecipeTitle());
Platform.runLater(() -> {
logger.info("Focused recipe title edit box.");
this.recipeDetailController.editRecipeTitle();
});
});
recipeList.refresh();
} catch (IOException | InterruptedException e) {

View file

@ -15,6 +15,7 @@ import javafx.util.StringConverter;
import java.io.InputStream;
import java.util.Locale;
import java.util.logging.Logger;
/**
* The language selection menu controller.
@ -22,6 +23,7 @@ import java.util.Locale;
* <code>getLocaleString(String)</code> function is available.
*/
public class LangSelectMenuCtrl implements LocaleAware {
private final Logger logger = Logger.getLogger(LangSelectMenuCtrl.class.getName());
public ComboBox<String> langSelectMenu;
private final LocaleManager manager;
@ -37,6 +39,7 @@ public class LangSelectMenuCtrl implements LocaleAware {
@FXML
private void switchLocale(ActionEvent event) {
String lang = langSelectMenu.getSelectionModel().getSelectedItem();
logger.info("Switching locale to " + lang);
manager.setLocale(Locale.of(lang));
}

View file

@ -207,7 +207,7 @@ public class RecipeDetailCtrl implements LocaleAware {
try {
server.updateRecipe(this.recipe);
this.refresh();
// this.refresh();
} catch (IOException | InterruptedException e) {
// throw a nice blanket UpdateException
throw new UpdateException("Error occurred when updating recipe name!");

View file

@ -7,10 +7,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Path;
import java.io.File;
import java.io.IOException;
import java.util.logging.Logger;
public class ConfigService {
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
private final Logger logger = Logger.getLogger(ConfigService.class.getName());
private Config config;
@ -70,9 +72,10 @@ public class ConfigService {
try {
File file = configPath.toFile(); // file is the config file here
mapper.writeValue(file, config); // here we edit the value of the file using config
logger.info("Config saved to " + file.getAbsolutePath());
}
catch (Exception e){
catch (Exception e) {
logger.severe("Something bad happened while saving the config: " + e.getMessage());
throw new RuntimeException(e);
}
}

View file

@ -18,12 +18,14 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;
public class ServerUtils {
private static final String SERVER = "http://localhost:8080/api";
private Logger logger = Logger.getLogger(ServerUtils.class.getName());
private final HttpClient client;
private final ObjectMapper objectMapper = new ObjectMapper();
private final int statusOK = 200;
@ -48,9 +50,10 @@ public class ServerUtils {
throw new IOException("No recipe to get. Server responds with " + response.body());
}
return objectMapper.readValue(response.body(), new TypeReference<List<Recipe>>() {
});// JSON string-> List<Recipe> (Jackson)
List<Recipe> list = objectMapper.readValue(response.body(), new TypeReference<List<Recipe>>() {
});
logger.info("Received response from server: " + list);
return list; // JSON string-> List<Recipe> (Jackson)
}
public List<Recipe> getRecipesFiltered(String filter) throws IOException, InterruptedException {

View file

@ -10,8 +10,10 @@ import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.logging.Logger;
public class WebSocketDataService<ID, Value> {
private Logger logger = Logger.getLogger(WebSocketDataService.class.getName());
public WebSocketDataService() {
}
@ -49,7 +51,11 @@ public class WebSocketDataService<ID, Value> {
*/
public boolean add(ID id, Consumer<Value> onComplete) {
CompletableFuture<Value> future = new CompletableFuture<>();
future.thenAccept(onComplete.andThen(_ -> pendingRegister.remove(id)));
future.thenAccept(onComplete.andThen(_ -> {
logger.info("Item " + id + " resolved. Removing from pending register.");
pendingRegister.remove(id);
}));
logger.info("Item " + id + " pending propagation. Adding to pending register.");
return pendingRegister.putIfAbsent(id, future) == null;
}
public boolean add(ID id) {

View file

@ -14,11 +14,13 @@ import java.lang.reflect.Type;
import java.util.function.Consumer;
import javax.annotation.Nullable;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Logger;
public class WebSocketUtils {
private static final String WS_URL = "ws://localhost:8080/updates";
private WebSocketStompClient stompClient;
private StompSession stompSession;
private Logger logger = Logger.getLogger(WebSocketUtils.class.getName());
/**
* Connect to the websocket server.
@ -36,21 +38,19 @@ public class WebSocketUtils {
@Override
public void afterConnected(StompSession session, StompHeaders connectedHeaders) {
stompSession = session;
System.out.println("WebSocket connected: " + session.getSessionId());
logger.info("WebSocket connected with session ID: " + session.getSessionId());
}
@Override
public void handleException(StompSession session, @Nullable StompCommand command,
StompHeaders headers, byte[] payload,
Throwable exception) {
System.err.println("STOMP error: " + exception.getMessage());
exception.printStackTrace();
logger.severe("STOMP error: " + exception.getMessage());
}
@Override
public void handleTransportError(StompSession session, Throwable exception) {
System.err.println("STOMP transport error: " + exception.getMessage());
exception.printStackTrace();
logger.severe("STOMP transport error: " + exception.getMessage());
}
}
);

View file

@ -103,6 +103,14 @@ public class Ingredient {
public int hashCode() {
return Objects.hash(id, name, proteinPer100g, fatPer100g, carbsPer100g);
}
@Override
public String toString() {
return "Ingredient " + id + " - " + name +
"= P:" + proteinPer100g +
"/F:" + fatPer100g +
"/C:" + carbsPer100g + " per 100g";
}
}

View file

@ -15,6 +15,7 @@
*/
package commons;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
@ -60,7 +61,7 @@ public class Recipe {
// | 1 (Steak) | 40g pepper |
// | 1 (Steak) | Meat |
// |----------------------------------|
@OneToMany
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@CollectionTable(name = "recipe_ingredients", joinColumns = @JoinColumn(name = "recipe_id"))
@Column(name = "ingredient")
// TODO: Replace String with Embeddable Ingredient Class
@ -157,12 +158,10 @@ public class Recipe {
@Override
public String toString() {
return "Recipe{" +
"id=" + id +
", name='" + name + '\'' +
", ingredientsCount=" + ingredients.size() +
", preparationStepsCount=" + preparationSteps.size() +
"}";
return "Recipe " + id +
" - " + name +
": " + ingredients.size() + " ingredients / " +
preparationSteps.size() + " steps";
}
@SuppressWarnings("unused")

View file

@ -1,5 +1,6 @@
package server.api;
import commons.Ingredient;
import commons.ws.Topics;
import commons.ws.messages.CreateIngredientMessage;
@ -93,6 +94,7 @@ public class IngredientController {
*
* @see Ingredient
*/
@GetMapping("/ingredients/{id}")
public ResponseEntity<Ingredient> getIngredientById(@PathVariable Long id) {
return ingredientService.findById(id)
@ -100,6 +102,19 @@ public class IngredientController {
.orElseGet(() -> ResponseEntity.notFound().build());
}
@GetMapping("/ingredients/{id}/usage")
public ResponseEntity<IngredientUsageResponse> getIngredientUsage(@PathVariable Long id) {
if (ingredientService.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
// Server-side computation of how many recipes reference this ingredient
long usedInRecipes = ingredientService.countUsage(id);
return ResponseEntity.ok(new IngredientUsageResponse(id, usedInRecipes));
}
/**
* Update an existing ingredient by its ID.
* Maps to <code>PATCH /api/ingredients/{id}</code>
@ -126,7 +141,7 @@ public class IngredientController {
updated.setId(id);
return ingredientService.update(id, updated)
.map(saved -> {
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new CreateIngredientMessage(saved));
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new UpdateIngredientMessage(saved));
return ResponseEntity.ok(saved);
})
.orElseGet(() -> ResponseEntity.notFound().build());
@ -139,7 +154,7 @@ public class IngredientController {
* <p>
* If an ingredient with the same name already exists,
* returns 400 Bad Request.
*
* <p>
* If the ingredient is created successfully,
* returns the created ingredient with 200 OK.
* </p>
@ -157,7 +172,7 @@ public class IngredientController {
return ingredientService.create(ingredient)
.map(saved -> {
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new UpdateIngredientMessage(saved));
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new CreateIngredientMessage(saved));
return ResponseEntity.ok(saved);
})
.orElseGet(() -> ResponseEntity.badRequest().build());
@ -186,4 +201,6 @@ public class IngredientController {
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new DeleteIngredientMessage(id));
return ResponseEntity.ok(true);
}
public record IngredientUsageResponse(Long ingredientId, long usedInRecipes){}
}

View file

@ -25,17 +25,21 @@ import server.service.RecipeService;
import java.util.List;
import java.util.Optional;
import java.util.logging.Logger;
@RestController
@RequestMapping("/api")
public class RecipeController {
private static final Logger logger = Logger.getLogger(RecipeController.class.getName());
private SimpMessagingTemplate messagingTemplate;
private RecipeService recipeService;
private RecipeRepository recipeRepository;
private final SimpMessagingTemplate messagingTemplate;
private final RecipeService recipeService;
public RecipeController(RecipeService recipeService, SimpMessagingTemplate messagingTemplate) {
this.recipeService = recipeService;
this.messagingTemplate = messagingTemplate;
logger.info("Initialized controller.");
}
/**
@ -48,6 +52,7 @@ public class RecipeController {
*/
@GetMapping("/recipe/{id}")
public ResponseEntity<Recipe> getRecipe(@PathVariable Long id) {
logger.info("GET /recipe/" + id + " called.");
return recipeService.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
@ -62,6 +67,7 @@ public class RecipeController {
*/
@GetMapping("/recipes")
public ResponseEntity<List<Recipe>> getRecipes(@RequestParam Optional<Integer> limit) {
logger.info("GET /recipes called.");
return ResponseEntity.ok(
// Choose the right overload. One has a limit, other doesn't.
limit.map(recipeService::findAll).orElseGet(recipeService::findAll)
@ -78,6 +84,7 @@ public class RecipeController {
*/
@PostMapping("/recipe/{id}")
public ResponseEntity<Recipe> updateRecipe(@PathVariable Long id, @RequestBody Recipe recipe) {
logger.info("POST /recipe/" + id + " called.");
return recipeService.update(id, recipe)
.map(saved -> {
messagingTemplate.convertAndSend(Topics.RECIPES, new UpdateRecipeMessage(saved)); // Send to WS.
@ -98,6 +105,7 @@ public class RecipeController {
*/
@PutMapping("/recipe/new")
public ResponseEntity<Recipe> createRecipe(@RequestBody Recipe recipe) {
logger.info("POST /recipe/new called.");
return recipeService.create(recipe)
.map(saved -> {
messagingTemplate.convertAndSend(Topics.RECIPES, new CreateRecipeMessage(saved)); // Send to WS.
@ -116,6 +124,7 @@ public class RecipeController {
*/
@DeleteMapping("/recipe/{id}")
public ResponseEntity<Boolean> deleteRecipe(@PathVariable Long id) {
logger.info("DELETE /recipe/" + id + " called.");
if (!recipeService.delete(id)) {
return ResponseEntity.badRequest().build();
}

View file

@ -4,16 +4,21 @@ import commons.Ingredient;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import server.database.IngredientRepository;
import server.database.RecipeIngredientRepository;
import java.util.List;
import java.util.Optional;
@Service
public class IngredientService {
IngredientRepository ingredientRepository;
public IngredientService(IngredientRepository ingredientRepository) {
private final IngredientRepository ingredientRepository;
private final RecipeIngredientRepository recipeIngredientRepository;
public IngredientService(IngredientRepository ingredientRepository,
RecipeIngredientRepository recipeIngredientRepository) {
this.ingredientRepository = ingredientRepository;
this.recipeIngredientRepository = recipeIngredientRepository;
}
public Optional<Ingredient> findById(Long id) {
@ -30,12 +35,16 @@ public class IngredientService {
/**
* Creates a new ingredient. Returns empty if the recipe with the same name or id already exists.
*
* @param ingredient Ingredient to be saved in the db.
* @return The created ingredient (the ingredient arg with a new assigned id) or empty if it already exists in db.
*/
public Optional<Ingredient> create(Ingredient ingredient) {
if (ingredientRepository.existsByName(ingredient.getName()) ||
ingredientRepository.existsById(ingredient.getId())) {
if (ingredient == null || ingredient.getName() == null) {
return Optional.empty();
}
if (ingredientRepository.existsByName(ingredient.getName())) {
return Optional.empty();
}
@ -44,6 +53,7 @@ public class IngredientService {
/**
* Updates an ingredient. The ingredient with the provided id will be replaced (in db) with the provided ingredient.
*
* @param id id of the ingredient to update.
* @param ingredient Ingredient to be saved in the db.
* @return The created ingredient (the ingredient arg with a new assigned id.)
@ -64,4 +74,10 @@ public class IngredientService {
return true;
}
//actually counting the amount used in recipes
public long countUsage(long ingredientId) {
return recipeIngredientRepository.countByIngredientId(ingredientId);
}
}