Skip to content

Commit

Permalink
Merge pull request #23 from maze508/flexible-search
Browse files Browse the repository at this point in the history
Flexible search
  • Loading branch information
maze508 authored Mar 13, 2024
2 parents d0cbec8 + 4e4aa55 commit 99eb73f
Show file tree
Hide file tree
Showing 14 changed files with 483 additions and 63 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

## Main Features

#### List All Contacts
#### List All Contacts
>Format: `list`
#### Add New Contacts
Expand Down
70 changes: 57 additions & 13 deletions docs/UserGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,23 +113,67 @@ Examples:
* `edit 1 p/91234567 e/[email protected]` Edits the phone number and email address of the 1st person to be `91234567` and `[email protected]` respectively.
* `edit 2 n/Betsy Crower t/` Edits the name of the 2nd person to be `Betsy Crower` and clears all existing tags.

### Locating persons by name: `find`
### Search Contact `find`

Finds persons whose names contain any of the given keywords.
- Search feature supports search by name and/or tags **ONLY**.
- Finds all contacts whose names or tags matches the substring keyword provided.

Format: `find KEYWORD [MORE_KEYWORDS]`
General Format: `find FIELD/ KEYWORD FIELD/ KEYWORD ...`
- Where `FIELD` is either `n/` for name or `t/` for tag.
- `KEYWORD` is the keyword (**alphabets only**) to search for.

#### Search Guidelines

* 'KEYWORD' can **ONLY** be alphabets and **CANNOT** contain spaces or be empty.
* e.g. `find n/John Doe` will **NOT** work. Try `find n/John n/Doe` instead to represent finding John and Doe
* e.g. `find n/` will **NOT** work as 'KEYWORD' cannot be empty.
* e.g. `find n/John123` will **NOT** work as 'KEYWORD' cannot contain non-alphabetic characters.


* 'KEYWORD' and next 'FIELD' should be separated by a space.
* e.g. `find n/John t/friends` will find all instances of John that have the tag friends
* but `find n/Johnt/tfriends` will instead return an error since it assumes you are searching for 'Johnt/tfriends'
* and there should not be non-alphabetic characters in the 'KEYWORD' field.


* Multiple of the same 'FIELDs' will be treated as a **Logical AND (&&)**.
* e.g. `find n/John n/Doe` will return all instances of John and Doe.
* e.g. `find n/Ale n/le` will still return the following example instances ["Alex Liew", "Alexis Lebrun", "Alec"]


* 'KEYWORD' should **NOT** be empty and there should be at least one 'FIELD' and 'KEYWORD' pair.
* e.g. `find n/ t/` and `find ` will **NOT** work.


* There should not be prefixes before the first 'FIELD' and 'KEYWORD' pair.
* e.g. `find testing123 n/John` will **NOT** work.


* The search is case-insensitive.
* e.g. `find n/hans` will match `Hans Niemann` and `Hans Zimmer`

* The order of the keywords does not matter.
* e.g. Results of `find n/Hans n/Bo` will match the results of`find n/Bo n/Hans`

* You can have multiple of the same 'FIELD's.
* e.g. `find n/J n/Do` will match names with `J` AND `Do`, like `John Doe`

* The search is case-insensitive. e.g `hans` will match `Hans`
* The order of the keywords does not matter. e.g. `Hans Bo` will match `Bo Hans`
* Only the name is searched.
* Only full words will be matched e.g. `Han` will not match `Hans`
* Persons matching at least one keyword will be returned (i.e. `OR` search).
e.g. `Hans Bo` will return `Hans Gruber`, `Bo Yang`

Examples:
* `find John` returns `john` and `John Doe`
* `find alex david` returns `Alex Yeoh`, `David Li`<br>
![result for 'find alex david'](images/findAlexDavidResult.png)
* `find n/Joh` returns `john`, `John Doe` and `Johnann Sebastian Bach`

* `find n/alex n/david` returns `Alex Davidson` and `David Alexis`

* `find n/Alex t/friends` returns `Alex Yeoh` who is tagged as a `friend`

* `find n////` returns an error message as the 'KEYWORD' field must consist of alphabets only

* `find n/` or `find t/` or `find n/ t/` returns an error message as the 'KEYWORD' field cannot be empty

* `find` returns an error message as there should be at least one 'FIELD' and 'KEYWORD' pair

* `find testing123 n/John` returns an error message as there should not be
prefixes before the first 'FIELD' and 'KEYWORD' pair

### Deleting a person : `delete`

Expand Down Expand Up @@ -199,6 +243,6 @@ Action | Format, Examples
**Clear** | `clear`
**Delete** | `delete INDEX`<br> e.g., `delete 3`
**Edit** | `edit INDEX [n/NAME] [p/PHONE_NUMBER] [e/EMAIL] [a/ADDRESS] [t/TAG]…​`<br> e.g.,`edit 2 n/James Lee e/[email protected]`
**Find** | `find KEYWORD [MORE_KEYWORDS]`<br> e.g., `find James Jake`
**Find** | `find KEYWORD/ [KEYWORD]`<br> e.g., `find n/ James n/ T t/ friend t/ rich`
**List** | `list`
**Help** | `help`
20 changes: 20 additions & 0 deletions src/main/java/seedu/address/commons/util/StringUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ public static boolean containsWordIgnoreCase(String sentence, String word) {
.anyMatch(preppedWord::equalsIgnoreCase);
}

/**
* Returns true if the {@code sentence} contains the {@code query} as a substring, ignoring case.
* <br>examples:<pre>
* containsSubstringIgnoreCase("Alexis Lebrun", "Ale") == true
* containsSubstringIgnoreCase("Alexis Lebrun", "le") == true
* containsSubstringIgnoreCase("Alex Yeoh", "python") == false
* </pre>
* @param sentence cannot be null
* @param query cannot be null, cannot be empty
*/
public static boolean containsSubstringIgnoreCase(String sentence, String query) {
requireNonNull(sentence);
requireNonNull(query);

String preppedQuery = query.trim();
checkArgument(!preppedQuery.isEmpty(), "Query parameter cannot be empty");

return sentence.toLowerCase().contains(preppedQuery.toLowerCase());
}

/**
* Returns a detailed message of the t, including the stack trace.
*/
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/seedu/address/logic/Messages.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public class Messages {
public static final String MESSAGE_PERSONS_LISTED_OVERVIEW = "%1$d persons listed!";
public static final String MESSAGE_DUPLICATE_FIELDS =
"Multiple values specified for the following single-valued field(s): ";
public static final String MESSAGE_ALPHABET_ONLY = "Value for %1$s must consist of alphabets only.";

public static final String MESSAGE_CANNOT_BE_EMPTY = "Keyword Value of FIELD %1$s cannot be empty.";

/**
* Returns an error message indicating the duplicate prefixes.
Expand Down
14 changes: 8 additions & 6 deletions src/main/java/seedu/address/logic/commands/FindCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,26 @@
import seedu.address.commons.util.ToStringBuilder;
import seedu.address.logic.Messages;
import seedu.address.model.Model;
import seedu.address.model.person.NameContainsKeywordsPredicate;
import seedu.address.model.person.NameAndTagContainsKeywordsPredicate;

/**
* Finds and lists all persons in address book whose name contains any of the argument keywords.
* Keyword matching is case insensitive.
* Keyword matching is case-insensitive.
*/
public class FindCommand extends Command {

public static final String COMMAND_WORD = "find";

public static final String MESSAGE_USAGE = COMMAND_WORD + ": Finds all persons whose names contain any of "
+ "the specified keywords (case-insensitive) and displays them as a list with index numbers.\n"
+ "Parameters: KEYWORD [MORE_KEYWORDS]...\n"
+ "Example: " + COMMAND_WORD + " alice bob charlie";
+ "Parameters: TYPE/KEYWORD TYPE/KEYWORD ... \n"
+ "Where TYPE can be n/ or t/ to specify name or tag respectively.\n"
+ "Example: " + COMMAND_WORD + " n/alice n/bob t/friends t/owesMoney \n"
+ "Please Refer to User Guide for more details.";

private final NameContainsKeywordsPredicate predicate;
private final NameAndTagContainsKeywordsPredicate predicate;

public FindCommand(NameContainsKeywordsPredicate predicate) {
public FindCommand(NameAndTagContainsKeywordsPredicate predicate) {
this.predicate = predicate;
}

Expand Down
34 changes: 34 additions & 0 deletions src/main/java/seedu/address/logic/parser/ArgumentMultimap.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import seedu.address.logic.Messages;
import seedu.address.logic.parser.exceptions.ParseException;



/**
* Stores mapping of prefixes to their respective arguments.
* Each key may be associated with multiple argument values.
Expand Down Expand Up @@ -75,4 +77,36 @@ public void verifyNoDuplicatePrefixesFor(Prefix... prefixes) throws ParseExcepti
throw new ParseException(Messages.getErrorMessageForDuplicatePrefixes(duplicatedPrefixes));
}
}

/**
* Throws a {@code ParseException} if any of the keywords in the prefixes given in {@code prefixes} are empty.
*/
public void verifyNonEmptyKeywordValues(Prefix... prefixes) throws ParseException {
for (Prefix prefix : prefixes) {
if (argMultimap.containsKey(prefix) && argMultimap.get(prefix).stream().anyMatch(String::isEmpty)) {
throw new ParseException(String.format(Messages.MESSAGE_CANNOT_BE_EMPTY, prefix));
}
}
}

/**
* Throws a {@code ParseException} if any of the keywords in the prefixes given in {@code prefixes}
* are not alphabets.
*/
public void verifyAllValuesAlpha(Prefix... prefixes) throws ParseException {
for (Prefix prefix : prefixes) {
List<String> values = getAllValues(prefix);
checkValuesAlpha(values, prefix);
}
}

/**
* Throws a {@code ParseException} if any of the characters in the prefixes given in {@code prefixes}
* are not alphabets.
*/
private void checkValuesAlpha(List<String> values, Prefix prefix) throws ParseException {
if (values.stream().anyMatch(value -> !value.matches("^[a-zA-Z]+$"))) {
throw new ParseException(String.format(Messages.MESSAGE_ALPHABET_ONLY, prefix.getPrefix()));
}
}
}
35 changes: 27 additions & 8 deletions src/main/java/seedu/address/logic/parser/FindCommandParser.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package seedu.address.logic.parser;


import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT;
import static seedu.address.logic.parser.CliSyntax.PREFIX_NAME;
import static seedu.address.logic.parser.CliSyntax.PREFIX_TAG;

import java.util.Arrays;
import java.util.List;

import seedu.address.logic.commands.FindCommand;
import seedu.address.logic.parser.exceptions.ParseException;
import seedu.address.model.person.NameContainsKeywordsPredicate;
import seedu.address.model.person.NameAndTagContainsKeywordsPredicate;

/**
* Parses input arguments and creates a new FindCommand object
Expand All @@ -18,16 +21,32 @@ public class FindCommandParser implements Parser<FindCommand> {
* and returns a FindCommand object for execution.
* @throws ParseException if the user input does not conform the expected format
*/
@Override
public FindCommand parse(String args) throws ParseException {
String trimmedArgs = args.trim();
if (trimmedArgs.isEmpty()) {
throw new ParseException(
String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE));
ArgumentMultimap argMultimap =
ArgumentTokenizer.tokenize(args, PREFIX_NAME, PREFIX_TAG);

// Ensure no preamble exists.
if (!argMultimap.getPreamble().isEmpty()) {
throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE));
}

String[] nameKeywords = trimmedArgs.split("\\s+");
// Check for non-empty Keywords after all Prefixes
argMultimap.verifyNonEmptyKeywordValues(PREFIX_NAME, PREFIX_TAG);

// Ensure all keywords are alphabets
argMultimap.verifyAllValuesAlpha(PREFIX_NAME, PREFIX_TAG);

List<String> nameKeywords = argMultimap.getAllValues(PREFIX_NAME);
List<String> tagKeywords = argMultimap.getAllValues(PREFIX_TAG);

return new FindCommand(new NameContainsKeywordsPredicate(Arrays.asList(nameKeywords)));
// Ensure that at least one name or tag keyword is provided
if (nameKeywords.isEmpty() && tagKeywords.isEmpty()) {
throw new ParseException(String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE));
}

return new FindCommand(new NameAndTagContainsKeywordsPredicate(nameKeywords, tagKeywords));
}

}

Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package seedu.address.model.person;

import java.util.List;
import java.util.Objects;
import java.util.function.Predicate;

import seedu.address.commons.util.StringUtil;


/**
* Tests that a {@code Person}'s {@code Name} and {@code Tag} matches any of the keywords given.
*/
public class NameAndTagContainsKeywordsPredicate implements Predicate<Person> {
private final List<String> nameKeywords;
private final List<String> tagKeywords;

/**
* Constructor for NameAndTagContainsKeywordsPredicate.
* @param nameKeywords List of name keywords to search for.
* @param tagKeywords List of tag keywords to search for.
*/
public NameAndTagContainsKeywordsPredicate(List<String> nameKeywords, List<String> tagKeywords) {
this.nameKeywords = nameKeywords;
this.tagKeywords = tagKeywords;
}

@Override
public boolean test(Person person) {
boolean matchesName = nameKeywords.stream()
.allMatch(keyword -> StringUtil.containsSubstringIgnoreCase(person.getName().fullName, keyword));
boolean matchesTags = tagKeywords.stream()
.allMatch(keyword -> person.getTags().stream()
.anyMatch(tag -> StringUtil.containsSubstringIgnoreCase(tag.tagName, keyword)));
return matchesName && matchesTags;
}

@Override
public boolean equals(Object other) {
if (other == this) {
return true;
}

// instanceof handles nulls
if (!(other instanceof NameAndTagContainsKeywordsPredicate)) {
return false;
}

NameAndTagContainsKeywordsPredicate that = (NameAndTagContainsKeywordsPredicate) other;
return Objects.equals(nameKeywords, that.nameKeywords)
&& Objects.equals(tagKeywords, that.tagKeywords);
}

@Override
public String toString() {
return "NameAndTagContainsKeywordsPredicate{"
+ "nameKeywords=" + nameKeywords
+ ", tagKeywords=" + tagKeywords
+ '}';
}
}
41 changes: 41 additions & 0 deletions src/test/java/seedu/address/commons/util/StringUtilTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,47 @@ public void containsWordIgnoreCase_validInputs_correctResult() {
assertTrue(StringUtil.containsWordIgnoreCase("AAA bBb ccc bbb", "bbB"));
}

//---------------- Tests for containsSubstringIgnoreCase --------------------------------------

@Test
public void containsSubstringIgnoreCase_nullSentence_throwsNullPointerException() {
assertThrows(NullPointerException.class, () -> StringUtil.containsSubstringIgnoreCase(null, "abc"));
}

@Test
public void containsSubstringIgnoreCase_nullQuery_throwsNullPointerException() {
assertThrows(NullPointerException.class, () ->
StringUtil.containsSubstringIgnoreCase("typical sentence", null));
}

@Test
public void containsSubstringIgnoreCase_emptyQuery_throwsIllegalArgumentException() {
assertThrows(IllegalArgumentException.class, "Query parameter cannot be empty", ()
-> StringUtil.containsSubstringIgnoreCase("typical sentence", " "));
}

@Test
public void containsSubstringIgnoreCase_validInputs_correctResult() {
// Matches as a substring
assertTrue(StringUtil.containsSubstringIgnoreCase("Alexander Hamilton", "Alex")); // Prefix match
assertTrue(StringUtil.containsSubstringIgnoreCase("Theresa May", "ere")); // Substring match
assertTrue(StringUtil.containsSubstringIgnoreCase("Barack Obama", "ack o")); // Across words

// No match
assertFalse(StringUtil.containsSubstringIgnoreCase("", "abc")); // Empty sentence
assertFalse(StringUtil.containsSubstringIgnoreCase(" ", "abc")); // Spaces in sentence
assertThrows(IllegalArgumentException.class, () ->
StringUtil.containsSubstringIgnoreCase("some sentence", "")); // Empty query
assertThrows(IllegalArgumentException.class, () ->
StringUtil.containsSubstringIgnoreCase("another sentence", " ")); // Space query
assertFalse(StringUtil.containsSubstringIgnoreCase("Theresa May",
"Theresa May")); // Double spaces in query

// Case-insensitive match
assertTrue(StringUtil.containsSubstringIgnoreCase("alexander hamilton", "Alex"));
assertTrue(StringUtil.containsSubstringIgnoreCase("Alexander HAMILTON", "alex"));
}

//---------------- Tests for getDetails --------------------------------------

/*
Expand Down
Loading

0 comments on commit 99eb73f

Please sign in to comment.