From e1f7fd3ac706c61cde99d31e0590fcca9bbfeb72 Mon Sep 17 00:00:00 2001 From: Ashmita Haldar <67264881+ashmitahaldar@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:48:23 +0800 Subject: [PATCH] FindCommand: Add fuzzy and strict search support The Find command only returns results for search terms that have some characters matching with the task descriptions. The user experience can be enhanced with more flexibility in search, e.g. finding items even with typos in search terms. Let's thus add support for fuzzy search and strict search in the Find Command, along with a SearchMatcher class that computes similarities between strings using Levenshtein distance. --- .../java/mayobot/commands/FindCommand.java | 140 ++++++++++++++---- src/main/java/mayobot/task/TaskList.java | 41 +++++ src/main/java/mayobot/util/SearchMatcher.java | 80 ++++++++++ 3 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 src/main/java/mayobot/util/SearchMatcher.java diff --git a/src/main/java/mayobot/commands/FindCommand.java b/src/main/java/mayobot/commands/FindCommand.java index ce0d5ffb8f..510daf1add 100644 --- a/src/main/java/mayobot/commands/FindCommand.java +++ b/src/main/java/mayobot/commands/FindCommand.java @@ -24,6 +24,15 @@ public class FindCommand extends Command { private static final String MISSING_SEARCH_TERM_MESSAGE = "Please specify a search term."; private static final String NO_TASK_FOUND_MESSAGE = "No matching tasks found."; + private static final String FUZZY_SEARCH_INDICATOR = " (fuzzy search)"; + private static final String STRICT_SEARCH_INDICATOR = " (exact word search)"; + + private static final double FUZZY_THRESHOLD = 0.7; // 70% similarity threshold + + private final boolean fuzzySearch; + private final boolean strictSearch; + private final String searchTerm; + /** * Constructs a new FindCommand with the specified search arguments. * @@ -31,6 +40,20 @@ public class FindCommand extends Command { */ public FindCommand(String arguments) { super("find", arguments); + + String trimmedArgs = arguments.trim(); + + this.fuzzySearch = trimmedArgs.startsWith("--fuzzy"); + this.strictSearch = trimmedArgs.startsWith("--strict"); + + // Extract search term by removing flags + if (fuzzySearch) { + this.searchTerm = trimmedArgs.replaceFirst("^--fuzzy\\s*", "").trim(); + } else if (strictSearch) { + this.searchTerm = trimmedArgs.replaceFirst("^--strict\\s*", "").trim(); + } else { + this.searchTerm = trimmedArgs; + } } /** @@ -49,35 +72,102 @@ public FindCommand(String arguments) { */ @Override public String execute(Ui ui, TaskList taskList, boolean isGui) throws MayoBotException { - String arguments = this.getArguments(); - if (arguments.trim().isEmpty()) { + if (searchTerm.isEmpty()) { + String helpMessage = buildHelpMessage(); if (!isGui) { - ui.showMessage(MISSING_SEARCH_TERM_MESSAGE); + ui.showMessage(helpMessage); } - return buildResponse(MISSING_SEARCH_TERM_MESSAGE); + return buildResponse(helpMessage); + } + + ArrayList matchingTasks = performSearch(taskList); + + if (matchingTasks.isEmpty()) { + String noResultsMessage = buildNoResultsMessage(); + if (!isGui) { + ui.showMessage(noResultsMessage); + } + return buildResponse(noResultsMessage); + } + + return buildSearchResults(ui, matchingTasks, isGui); + } + + /** + * Performs the appropriate search based on the search mode. + */ + private ArrayList performSearch(TaskList taskList) { + if (fuzzySearch) { + return taskList.findTasksFuzzy(searchTerm, FUZZY_THRESHOLD); + } else if (strictSearch) { + return taskList.findTasksStrict(searchTerm); } else { - ArrayList matchingTasks = taskList.findTasks(arguments.trim()); - if (matchingTasks.isEmpty()) { - if (!isGui) { - ui.showMessage(NO_TASK_FOUND_MESSAGE); - } - return buildResponse(NO_TASK_FOUND_MESSAGE); - } else { - StringBuilder response = new StringBuilder(); - if (!isGui) { - ui.showMessage(LIST_OUTPUT_HEADER); - } - response.append(LIST_OUTPUT_HEADER + "\n"); - for (Object[] matchingTask : matchingTasks) { - int index = (Integer) matchingTask[0]; - Task task = (Task) matchingTask[1]; - if (!isGui) { - ui.showMessage(index + ". " + task); - } - response.append(index + ". " + task + "\n"); - } - return buildResponse(response.toString()); + return taskList.findTasks(searchTerm); // Your existing partial match method + } + } + + /** + * Builds help message for invalid search input. + */ + private String buildHelpMessage() { + return MISSING_SEARCH_TERM_MESSAGE + "\n" + + "Usage:\n" + + " find - partial matching\n" + + " find --fuzzy - fuzzy matching (handles typos)\n" + + " find --strict - exact word matching"; + } + + /** + * Builds no results message with search mode indicator. + */ + private String buildNoResultsMessage() { + String searchMode = ""; + if (fuzzySearch) { + searchMode = FUZZY_SEARCH_INDICATOR; + } else if (strictSearch) { + searchMode = STRICT_SEARCH_INDICATOR; + } + + return NO_TASK_FOUND_MESSAGE + " for \"" + searchTerm + "\"" + searchMode; + } + + /** + * Builds and returns the search results response. + */ + private String buildSearchResults(Ui ui, ArrayList matchingTasks, boolean isGui) { + StringBuilder response = new StringBuilder(); + String header = buildSearchHeader(matchingTasks.size()); + + if (!isGui) { + ui.showMessage(header); + } + response.append(header).append("\n"); + + for (Object[] matchingTask : matchingTasks) { + int index = (Integer) matchingTask[0]; + Task task = (Task) matchingTask[1]; + String taskLine = index + ". " + task; + + if (!isGui) { + ui.showMessage(taskLine); } + response.append(taskLine).append("\n"); } + + return buildResponse(response.toString()); + } + + /** + * Builds the search results header with search mode indicator. + */ + private String buildSearchHeader(int resultCount) { + String searchMode = ""; + if (fuzzySearch) { + searchMode = FUZZY_SEARCH_INDICATOR; + } else if (strictSearch) { + searchMode = STRICT_SEARCH_INDICATOR; + } + + return LIST_OUTPUT_HEADER + " (" + resultCount + " found)" + searchMode; } } diff --git a/src/main/java/mayobot/task/TaskList.java b/src/main/java/mayobot/task/TaskList.java index 355497e1b0..b5fae6e5e5 100644 --- a/src/main/java/mayobot/task/TaskList.java +++ b/src/main/java/mayobot/task/TaskList.java @@ -1,11 +1,13 @@ package mayobot.task; import java.util.ArrayList; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.IntStream; import mayobot.Storage; import mayobot.ui.Ui; +import mayobot.util.SearchMatcher; /** * Manages a collection of tasks and provides operations for task manipulation. @@ -266,4 +268,43 @@ public ArrayList findTasks(String searchTerm) { .mapToObj(i -> new Object[]{i + 1, tasks.get(i)}) .collect(Collectors.toCollection(ArrayList::new)); } + + /** + * Finds tasks that match the search term using fuzzy matching with streams. + * This method handles typos and similar words using Levenshtein distance. + * The search is performed using Java streams for efficient processing. + * + * @param keyword the search term to match against + * @param threshold similarity threshold (0.0 to 1.0) + * @return list of matching tasks with their original indices + */ + public ArrayList findTasksFuzzy(String keyword, double threshold) { + assert keyword != null : "Search keyword cannot be null"; + assert !keyword.trim().isEmpty() : "Search keyword cannot be empty"; + assert threshold >= 0.0 && threshold <= 1.0 : "Threshold must be between 0.0 and 1.0"; + + return IntStream.range(0, tasks.size()) + .filter(i -> SearchMatcher.fuzzyMatch(keyword, tasks.get(i).getDescription(), threshold)) + .mapToObj(i -> new Object[]{i + 1, tasks.get(i)}) + .collect(Collectors.toCollection(ArrayList::new)); + } + + /** + * Finds tasks that contain the exact search term as a whole word using streams. + * This method performs exact word matching (case-insensitive). + * + * @param keyword the exact word to search for + * @return list of matching tasks with their original indices + */ + public ArrayList findTasksStrict(String keyword) { + assert keyword != null : "Search keyword cannot be null"; + assert !keyword.trim().isEmpty() : "Search keyword cannot be empty"; + + String searchPattern = "\\b" + Pattern.quote(keyword.toLowerCase()) + "\\b"; + + return IntStream.range(0, tasks.size()) + .filter(i -> tasks.get(i).getDescription().toLowerCase().matches(".*" + searchPattern + ".*")) + .mapToObj(i -> new Object[]{i + 1, tasks.get(i)}) + .collect(Collectors.toCollection(ArrayList::new)); + } } diff --git a/src/main/java/mayobot/util/SearchMatcher.java b/src/main/java/mayobot/util/SearchMatcher.java new file mode 100644 index 0000000000..9234910323 --- /dev/null +++ b/src/main/java/mayobot/util/SearchMatcher.java @@ -0,0 +1,80 @@ +package mayobot.util; + +/** + * Utility class for advanced string matching including fuzzy search capabilities. + */ +public class SearchMatcher { + + /** + * Calculates similarity between two strings using Levenshtein distance. + * Returns a value between 0.0 (no similarity) and 1.0 (identical). + */ + public static double calculateSimilarity(String s1, String s2) { + if (s1 == null || s2 == null) { + return 0.0; + } + if (s1.equals(s2)) { + return 1.0; + } + + int maxLength = Math.max(s1.length(), s2.length()); + if (maxLength == 0) { + return 1.0; + } + + return (maxLength - levenshteinDistance(s1, s2)) / (double) maxLength; + } + + /** + * Calculates Levenshtein distance between two strings. + */ + private static int levenshteinDistance(String s1, String s2) { + int[][] dp = new int[s1.length() + 1][s2.length() + 1]; + + for (int i = 0; i <= s1.length(); i++) { + dp[i][0] = i; + } + for (int j = 0; j <= s2.length(); j++) { + dp[0][j] = j; + } + + for (int i = 1; i <= s1.length(); i++) { + for (int j = 1; j <= s2.length(); j++) { + if (s1.charAt(i - 1) == s2.charAt(j - 1)) { + dp[i][j] = dp[i - 1][j - 1]; + } else { + dp[i][j] = 1 + Math.min(dp[i - 1][j], Math.min(dp[i][j - 1], dp[i - 1][j - 1])); + } + } + } + + return dp[s1.length()][s2.length()]; + } + + /** + * Checks if the search term matches the text with fuzzy matching. + * + * @param searchTerm the term to search for + * @param text the text to search in + * @param threshold similarity threshold (0.0 to 1.0) + * @return true if similarity is above threshold + */ + public static boolean fuzzyMatch(String searchTerm, String text, double threshold) { + String[] searchWords = searchTerm.toLowerCase().split("\\s+"); + String[] textWords = text.toLowerCase().split("\\s+"); + + for (String searchWord : searchWords) { + boolean found = false; + for (String textWord : textWords) { + if (calculateSimilarity(searchWord, textWord) >= threshold) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; + } +}