Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 115 additions & 25 deletions src/main/java/mayobot/commands/FindCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,36 @@ 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.
*
* @param arguments the search term or phrase to look for in task descriptions
*/
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;
}
}

/**
Expand All @@ -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<Object[]> 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<Object[]> performSearch(TaskList taskList) {
if (fuzzySearch) {
return taskList.findTasksFuzzy(searchTerm, FUZZY_THRESHOLD);
} else if (strictSearch) {
return taskList.findTasksStrict(searchTerm);
} else {
ArrayList<Object[]> 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 <search_term> - partial matching\n"
+ " find --fuzzy <search_term> - fuzzy matching (handles typos)\n"
+ " find --strict <search_term> - 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<Object[]> 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;
}
}
41 changes: 41 additions & 0 deletions src/main/java/mayobot/task/TaskList.java
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -266,4 +268,43 @@ public ArrayList<Object[]> 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<Object[]> 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<Object[]> 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));
}
}
80 changes: 80 additions & 0 deletions src/main/java/mayobot/util/SearchMatcher.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading