Skip to content

Commit

Permalink
Merge a258753 into 2d3daee
Browse files Browse the repository at this point in the history
  • Loading branch information
iskandarzulkarnaien committed Nov 7, 2019
2 parents 2d3daee + a258753 commit f0d09ca
Show file tree
Hide file tree
Showing 22 changed files with 379 additions and 113 deletions.
55 changes: 26 additions & 29 deletions docs/UserGuide.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -317,56 +317,53 @@ Edits the phone number of the specified patient to be `91234567`.
* `edit S8732457G n/Betsy Crower p/91234567` +
Edits the name and phone number of the specified donor to be `Betsy Crower` and `91234567` respectively.

// tag::find[]
=== Finding persons by attributes: `find`

You can use the `find` command to search for persons whose attributes match your input keywords. Matching persons will
be displayed in a list. +
You can use the `find` command to search for persons whose attributes match your input keywords. A list of matching
persons along with the number of exact and possible matches will be displayed. +

Format: `find __PREFIX__/__KEYWORD__`
Format: `find __PREFIX__/__KEYWORD__ (__PREFIX__/__KEYWORD__...)`

Optional parameters: Multiple `keywords` per `prefix`, multiple `prefixes` in the same find command.
Optional parameters: Multiple __keyword__s per __prefix__, multiple __prefixe__s in the same find command.

The find command performs case insensitive `OR` matching within a prefix and `AND` matching between prefixes. The
following example illustrates this concept:
[TIP]
You may wish to reference <<List of Attributes>>, to view the list of available prefixes to search by.

The find command is **case insensitive** and performs **OR** matching within a prefix and **AND** matching between
prefixes. It also matches similar looking words to account for possible typos in your keywords. The following example illustrates
these concepts:

.Find command with multiple prefixes and multiple keywords per prefix.
image::findByPrefix.png[width="790"]

As seen from Figure 12, `find n/Duncan n/Lionel n/Helen t/patient t/donor` displays 'Duncan Chua', `Lionel Doe` and
`Helen Davidson`. This is due to the fact that Duncan's name matches either of 'Duncan' `OR` 'Lionel' `OR` 'Helen' `AND`
he is also either a 'doctor' `OR` 'donor'. The same is true for Lionel and Helen. You should note that the only
exception to this rule is tissue type matching, which uses `AND` matching within the prefix.

You may wish to reference the following section: <<List of Attributes>>, to view the list of available prefixes to
search by.
In Figure 12, you can see that `find n/Duncan n/Loinel n/Helen t/patient t/donor` shows a list of 44 matches. The two
exact matches are listed at the top; those below are possible matches, sorted according to how closely they match your
keywords. 'Duncan Chua' and 'Helen Davidson' are among the matched persons due to the fact that Duncan's name matches
either of 'Duncan' **OR** 'Loinel' **OR** 'Helen' **AND** he is also either a 'patient' **OR** 'donor'. The same is true
for Helen. 'Lionel Lim' also appears in the search results as his name is similar to 'Loinel'. Hey, good thing we
picked up on that typo or we'd have missed Lionel!

Here are some examples and pointers showcasing the use of `find`:
NOTE: The only exception to this rule is tissue type matching, which uses `AND` matching within the prefix.

__Use case with single prefix and keyword__ +
* `find n/Alice` +
Displays all persons whose name contains 'Alice'

__Use case demonstrating keyword case insensitivity__ +
* `find n/aLiCe` +
Displays the same result as the preceding example.
Lets take a look at some examples and pointers showcasing the use of `find`:

__Use case with multiple keywords per prefix__ +
* `find n/Alice n/Benson b/A b/B b/O` +
Displays all persons whose name contains either 'Alice' or 'Benson' and whose blood type is either 'A', 'B' or 'O'.
* `find n/Laura n/Marisha Ray b/A b/B b/O` +
Displays all persons whose name contains either 'Laura' or 'Marisha Ray' and whose blood type is either 'A', 'B' or 'O'.

__Use case demonstrating prefix order insensitivity__ +
* `find b/A n/Benson b/O n/Alice b/B` +
* `find b/A n/Benson Carter b/O n/Alice b/B` +
Displays the same result as the preceding example.

__Use case demonstrating full name capture__ +
* `find n/Laura Bailey n/Marisha Ray` +
Displays all persons whose name contains 'Laura Bailey' or 'Marisha Ray'.

__Use case demonstrating tissue type finding__ +
* `find tt/4,1,2,3` +
Displays all persons whose tissue type contains tissues: 4, 1, 2 and 3; in any order.

[TIP]
Looking for exact matches? No problem! Just replace `find` with `exactFind`!

That's it for this section. Congratulations, you now know how to use the `find` command!

=== Detecting matches: `match`

This command runs a kidney matching test on patients and donors in ORGANice and displays the match results.
Expand Down
Binary file modified docs/images/findByPrefix.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions src/main/java/organice/commons/util/StringUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,52 @@ public static boolean isNonZeroUnsignedInteger(String s) {
return false;
}
}

// Algorithm taken from https://semanti.ca/blog/?an-introduction-into-approximate-string-matching.
// Java code is original work.
/**
* Calculates the Levenshtein Distance (edit distance) between the given {@code String firstString} and
* {@code String secondString}. Levenshtein Distance is the number of character edits required to morph one
* string into another.
*/
public static int calculateLevenshteinDistance(String firstString, String secondString) {
requireNonNull(firstString);
requireNonNull(secondString);

int firstStringLength = firstString.length();
int secondStringLength = secondString.length();

checkArgument(firstStringLength != 0 && secondStringLength != 0);

char[] pkChars = firstString.toCharArray();
char[] paChars = secondString.toCharArray();

int[][] costMatrix = new int[firstStringLength][secondStringLength];
for (int i = 0; i < firstStringLength; i++) {
for (int j = 0; j < secondStringLength; j++) {
costMatrix[i][j] = pkChars[i] == paChars[j] ? 0 : 1;
}
}

int[][] levenshteinMatrix = new int [firstStringLength + 1][secondStringLength + 1];
// Initialise first row and col to be in range 0..length
for (int r = 0; r < firstStringLength + 1; r++) {
levenshteinMatrix[r][0] = r;
}
for (int c = 0; c < secondStringLength + 1; c++) {
levenshteinMatrix[0][c] = c;
}

// Setting the distance
for (int r = 1; r < firstStringLength + 1; r++) {
for (int c = 1; c < secondStringLength + 1; c++) {
int cellAbovePlusOne = levenshteinMatrix[r - 1][c] + 1;
int cellLeftPlusOne = levenshteinMatrix[r][c - 1] + 1;
int cellLeftDiagPlusCost = levenshteinMatrix[r - 1][c - 1] + costMatrix[r - 1][c - 1];
levenshteinMatrix[r][c] =
Integer.min(cellAbovePlusOne, Integer.min(cellLeftPlusOne, cellLeftDiagPlusCost));
}
}
return levenshteinMatrix[firstStringLength][secondStringLength];
}
}
44 changes: 44 additions & 0 deletions src/main/java/organice/logic/commands/ExactFindCommand.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package organice.logic.commands;

import static java.util.Objects.requireNonNull;

import organice.commons.core.Messages;
import organice.model.Model;
import organice.model.person.PersonContainsPrefixesPredicate;

/**
* Finds and lists all persons in address book whose prefixes match any of the argument prefix-keyword pairs.
* Keyword matching is case insensitive.
*/
public class ExactFindCommand extends Command {

public static final String COMMAND_WORD = "exactFind";

public static final String MESSAGE_USAGE = COMMAND_WORD + ": Finds all persons whose prefixes match any of "
+ "the specified prefix-keywords pairs (case-insensitive) and displays them as a list with index numbers.\n"
+ "List of Prefixes: n/, ic/, p/, a/, t/, pr/, b/, d/, tt/, exp/, o/"
+ "Parameters: PREFIX/KEYWORD [MORE_PREFIX-KEYWORD_PAIRS]...\n"
+ "Example: " + COMMAND_WORD + " n/alice t/doctor";

private final PersonContainsPrefixesPredicate predicate;

public ExactFindCommand(PersonContainsPrefixesPredicate predicate) {
requireNonNull(predicate);
this.predicate = predicate;
}

@Override
public CommandResult execute(Model model) {
requireNonNull(model);
model.updateFilteredPersonList(predicate);
return new CommandResult(
String.format(Messages.MESSAGE_PERSONS_LISTED_OVERVIEW, model.getFilteredPersonList().size()));
}

@Override
public boolean equals(Object other) {
return other == this // short circuit if same object
|| (other instanceof ExactFindCommand // instanceof handles nulls
&& predicate.equals(((ExactFindCommand) other).predicate)); // state check
}
}
134 changes: 122 additions & 12 deletions src/main/java/organice/logic/commands/FindCommand.java
Original file line number Diff line number Diff line change
@@ -1,44 +1,154 @@
package organice.logic.commands;

import static java.util.Objects.requireNonNull;
import static organice.commons.util.StringUtil.calculateLevenshteinDistance;
import static organice.logic.parser.CliSyntax.PREFIX_NAME;
import static organice.logic.parser.CliSyntax.PREFIX_NRIC;
import static organice.logic.parser.CliSyntax.PREFIX_PHONE;
import static organice.logic.parser.CliSyntax.PREFIX_TYPE;

import organice.commons.core.Messages;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.function.BiFunction;

import javafx.collections.FXCollections;
import javafx.collections.transformation.FilteredList;

import organice.logic.parser.ArgumentMultimap;
import organice.model.Model;
import organice.model.person.Name;
import organice.model.person.Person;
import organice.model.person.PersonContainsPrefixesPredicate;


/**
* Finds and lists all persons in address book whose prefixes match any of the argument prefix-keyword pairs.
* Performs fuzzy searching based on Levenshtein Distance.
* 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 prefixes match any of "
+ "the specified prefix-keywords pairs (case-insensitive) and displays them as a list with index numbers.\n"
+ "List of Prefixes: n/, ic/, p/, a/, t/, pr/, b/, d/, tt/, exp/, o/"
public static final String MESSAGE_USAGE = COMMAND_WORD + ": Finds all persons whose prefixes are similar to any of"
+ " the specified prefix-keywords pairs (case-insensitive) and displays them as a list with index numbers."
+ "\nList of Prefixes: n/, ic/, p/, a/, t/, pr/, b/, d/, tt/, exp/, o/"
+ "Parameters: PREFIX/KEYWORD [MORE_PREFIX-KEYWORD_PAIRS]...\n"
+ "Example: " + COMMAND_WORD + " n/alice t/doctor";

private final PersonContainsPrefixesPredicate predicate;
// Maximum Levenshtein Distance tolerated for a fuzzy match
private static final int FUZZY_THRESHOLD = 5;

private final ArgumentMultimap argMultimap;

public FindCommand(ArgumentMultimap argMultimap) {
this.argMultimap = argMultimap;
}

/**
* Returns a copy of the {@code inputList} which is filtered according to maximum tolerable Levenshtein Distance
* (edit distance) then sorted according to ascending level of distance.
*/
private List<Person> fuzzyMatch(ArgumentMultimap argMultimap, List<Person> inputList) {
// Fuzzy Match by Levenshtein Distance is not implemented for following prefixes:
// Age, Priority, BloodType, TissueType
// Typos in these fields have a very small Levenshtein Distance (LD) as typical field length is very small

List<String> nameKeywords = argMultimap.getAllValues(PREFIX_NAME);
List<String> nricKeywords = argMultimap.getAllValues(PREFIX_NRIC);
List<String> phoneKeywords = argMultimap.getAllValues(PREFIX_PHONE);
List<String> typeKeywords = argMultimap.getAllValues(PREFIX_TYPE);

// List containing combined Levenshtein Distance of persons in inputList
ArrayList<Integer> distanceList = new ArrayList<>();

for (int i = 0; i < inputList.size(); i++) {
Person currentPerson = inputList.get(i);
int combinedLevenshteinDistance = 0;
combinedLevenshteinDistance += nameKeywords.isEmpty() ? 0
: findMinLevenshteinDistance(nameKeywords, currentPerson.getName());
combinedLevenshteinDistance += nricKeywords.isEmpty() ? 0
: findMinLevenshteinDistance(nricKeywords, currentPerson.getNric().toString());
combinedLevenshteinDistance += phoneKeywords.isEmpty() ? 0
: findMinLevenshteinDistance(phoneKeywords, currentPerson.getPhone().toString());
combinedLevenshteinDistance += typeKeywords.isEmpty() ? 0
: findMinLevenshteinDistance(typeKeywords, currentPerson.getType().toString());

distanceList.add(i, combinedLevenshteinDistance);
}

// Keep Persons whose Levenshtein Distance is within tolerable range
ArrayList<Integer> distancesOfTolerablePersons = new ArrayList<>();
ArrayList<Person> tolerablePersons = new ArrayList<>(inputList.size());
for (int i = 0; i < inputList.size(); i++) {
int levenshteinDistance = distanceList.get(i);
if (levenshteinDistance <= FUZZY_THRESHOLD) {
distancesOfTolerablePersons.add(levenshteinDistance);
tolerablePersons.add(inputList.get(i));
}
}

public FindCommand(PersonContainsPrefixesPredicate predicate) {
requireNonNull(predicate);
this.predicate = predicate;
ArrayList<Person> sortedPersons = new ArrayList<>(tolerablePersons);
sortedPersons.sort(Comparator.comparingInt(left ->
distancesOfTolerablePersons.get(tolerablePersons.indexOf(left))));

return sortedPersons;
}

private int findMinLevenshteinDistance(List<String> prefixKeywords, String personAttribute) {
return prefixKeywords.stream().reduce(Integer.MAX_VALUE, (minDistance, nextKeyword) -> Integer.min(
minDistance, calculateLevenshteinDistance(nextKeyword.toLowerCase(), personAttribute.toLowerCase())),
Integer::min);
}

/**
* Returns the minimum Levenshtein Distance of every {@code prefixKeyword} and {@code personName} pair. If
* {@code prefixKeyword} is a single-word string, {@code personName} is split according to spaces (if possible) and
* the minimum distance is calculated between every possible pair.
*/
private int findMinLevenshteinDistance(List<String> prefixKeywords, Name personName) {
// If keyword is one word long, split the personName and find minLD.
// Else just do as we are normally doing in original finalMinLD

BiFunction<String, String, Integer> findDistanceSplitIfMultiWord = (prefixKeyword, pName) ->
prefixKeyword.split(" ").length == 1 ? Arrays.stream(pName.split(" "))
.reduce(Integer.MAX_VALUE, (minDistance, nextNameWord) -> Integer.min(minDistance,
calculateLevenshteinDistance(prefixKeyword.toLowerCase(), nextNameWord.toLowerCase())),
Integer::min)
: calculateLevenshteinDistance(prefixKeyword.toLowerCase(), pName.toLowerCase());

return prefixKeywords.stream().reduce(Integer.MAX_VALUE, (minDistance, nextKeyword) ->
Integer.min(minDistance, findDistanceSplitIfMultiWord.apply(nextKeyword, personName.toString())),
Integer::min);
}

@Override
public CommandResult execute(Model model) {
requireNonNull(model);
model.updateFilteredPersonList(predicate);
return new CommandResult(
String.format(Messages.MESSAGE_PERSONS_LISTED_OVERVIEW, model.getFilteredPersonList().size()));

List<Person> allPersons = Arrays.asList(model.getFullPersonList().toArray(Person[]::new));

FilteredList<Person> exactMatches = new FilteredList<>(FXCollections.observableList(allPersons));
exactMatches.setPredicate(new PersonContainsPrefixesPredicate(argMultimap));

List<Person> allExceptExactMatches = new ArrayList<>(allPersons);
allExceptExactMatches.removeAll(Arrays.asList(exactMatches.toArray(Person[]::new)));
allExceptExactMatches = fuzzyMatch(argMultimap, allExceptExactMatches);

ArrayList<Person> finalArrayList = new ArrayList<>(exactMatches);
finalArrayList.addAll(allExceptExactMatches);

model.setDisplayedPersonList(finalArrayList);
return new CommandResult("Found " + exactMatches.size() + " exact matches and "
+ allExceptExactMatches.size() + " possible matches!");
}

@Override
public boolean equals(Object other) {
return other == this // short circuit if same object
|| (other instanceof FindCommand // instanceof handles nulls
&& predicate.equals(((FindCommand) other).predicate)); // state check
&& argMultimap.equals(((FindCommand) other).argMultimap)); // state check
}
}
5 changes: 4 additions & 1 deletion src/main/java/organice/logic/parser/AddressBookParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import organice.logic.commands.Command;
import organice.logic.commands.DoneCommand;
import organice.logic.commands.EditCommand;
import organice.logic.commands.ExactFindCommand;
import organice.logic.commands.ExitCommand;
import organice.logic.commands.FindCommand;
import organice.logic.commands.HelpCommand;
Expand All @@ -19,7 +20,6 @@
import organice.logic.commands.ProcessingCommand;
import organice.logic.commands.ProcessingMarkDoneCommand;
import organice.logic.commands.SortCommand;

import organice.logic.parser.exceptions.ParseException;

/**
Expand Down Expand Up @@ -61,6 +61,9 @@ public Command parseCommand(String userInput) throws ParseException {
case FindCommand.COMMAND_WORD:
return new FindCommandParser().parse(arguments);

case ExactFindCommand.COMMAND_WORD:
return new ExactFindCommandParser().parse(arguments);

case ListCommand.COMMAND_WORD:
return new ListCommandParser().parse(arguments);

Expand Down
2 changes: 1 addition & 1 deletion src/main/java/organice/logic/parser/DoneCommandParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import organice.logic.parser.exceptions.ParseException;

/**
* Parses input arguments and creates a new FindCommand object
* Parses input arguments and creates a new ExactFindCommand object
*/
public class DoneCommandParser implements Parser<DoneCommand> {

Expand Down
Loading

0 comments on commit f0d09ca

Please sign in to comment.