diff --git a/src/main/java/ted/Parser.java b/src/main/java/ted/Parser.java index 7817ce3e3d..50a858c8fe 100644 --- a/src/main/java/ted/Parser.java +++ b/src/main/java/ted/Parser.java @@ -4,6 +4,8 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.format.ResolverStyle; +import java.util.ArrayList; +import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -12,11 +14,14 @@ import ted.command.CommandType; import ted.command.DeleteCommand; import ted.command.ExitCommand; +import ted.command.FindByTagCommand; import ted.command.FindCommand; import ted.command.ListCommand; import ted.command.MarkCommand; +import ted.command.TagCommand; import ted.task.Deadline; import ted.task.Event; +import ted.task.Tag; import ted.task.Todo; /** @@ -24,9 +29,9 @@ *
* All of the fiddly work of reading user input lives here: splitting off the
* command word, finding {@code /by}, {@code /from} and {@code /to}, and
- * reading dates. Because a command is only built once its details make sense,
- * the command classes themselves are free of input checking, and this class
- * can be tested without a keyboard or a save file.
+ * reading dates and tags. Because a command is only built once its details
+ * make sense, the command classes themselves are free of input checking, and
+ * this class can be tested without a keyboard or a save file.
*/
public class Parser {
/**
@@ -72,7 +77,9 @@ public static Command parse(String input) throws TedException {
case MARK -> new MarkCommand(parseTaskIndex(argument, CommandType.MARK), true);
case UNMARK -> new MarkCommand(parseTaskIndex(argument, CommandType.UNMARK), false);
case DELETE -> new DeleteCommand(parseTaskIndex(argument, CommandType.DELETE));
- case FIND -> new FindCommand(parseKeyword(argument));
+ case FIND -> parseFind(argument);
+ case TAG -> parseTagCommand(argument, CommandType.TAG, true);
+ case UNTAG -> parseTagCommand(argument, CommandType.UNTAG, false);
case TODO -> new AddCommand(parseTodo(argument));
case DEADLINE -> new AddCommand(parseDeadline(argument));
case EVENT -> new AddCommand(parseEvent(argument));
@@ -80,6 +87,24 @@ public static Command parse(String input) throws TedException {
};
}
+ /**
+ * Reads a search. A keyword starting with {@code #} is a tag, which matches
+ * only the tasks that have exactly that tag; any other keyword is text to
+ * find inside descriptions.
+ *
+ * @param argument everything the user typed after the command word.
+ * @return the command that shows the matching tasks.
+ * @throws TedException if no keyword was given, or it starts with {@code #}
+ * but is not a valid tag.
+ */
+ private static Command parseFind(String argument) throws TedException {
+ String keyword = parseKeyword(argument);
+ if (keyword.startsWith(Tag.PREFIX)) {
+ return new FindByTagCommand(parseTag(keyword, "for example: find #fun"));
+ }
+ return new FindCommand(keyword);
+ }
+
/**
* Reads the keyword to search for.
*
@@ -167,6 +192,37 @@ private static Event parseEvent(String argument) throws TedException {
return new Event(description, start, end);
}
+ /**
+ * Reads a tag or untag command, from an argument of the form
+ * {@code
- * The line has the shape {@code
+ * Kept apart from {@link FindCommand} because matching a whole tag is a
+ * different rule from finding text inside a description. Like a keyword
+ * search, it only looks at the list, so nothing is saved.
+ */
+public class FindByTagCommand extends Command {
+ /** The tag a task must have to be shown. */
+ private final Tag tag;
+
+ /**
+ * Creates a command that shows the tasks with the given tag.
+ *
+ * @param tag the tag to look for.
+ */
+ public FindByTagCommand(Tag tag) {
+ this.tag = tag;
+ }
+
+ /** Shows the tasks with the tag. Nothing is changed, so nothing is saved. */
+ @Override
+ public void execute(TaskList tasks, Ui ui, Storage storage) {
+ ui.showMatchingTasks(tasks.findByTag(tag), tag.toString());
+ }
+}
diff --git a/src/main/java/ted/command/TagCommand.java b/src/main/java/ted/command/TagCommand.java
new file mode 100644
index 0000000000..4c9448ea5c
--- /dev/null
+++ b/src/main/java/ted/command/TagCommand.java
@@ -0,0 +1,77 @@
+package ted.command;
+
+import java.util.List;
+
+import ted.Storage;
+import ted.TedException;
+import ted.Ui;
+import ted.task.Tag;
+import ted.task.Task;
+import ted.task.TaskList;
+
+/**
+ * Attaches tags to a task, or detaches them.
+ * Tagging and untagging differ only in which way the task's tags change and in
+ * the wording, so one class covers both, the way {@link MarkCommand} covers
+ * mark and unmark.
+ */
+public class TagCommand extends Command {
+ /** Zero-based position of the task to change. */
+ private final int index;
+
+ /** The tags to attach or detach. */
+ private final List
+ * A tag is nothing more than its name, so it is written as a record: Java
+ * generates {@code equals} and {@code hashCode} from the name, which is what
+ * lets a task keep its tags in a {@link java.util.Set} without repeats. The
+ * name is stored in lower case, so {@code #Fun} and {@code #fun} are the same
+ * tag.
+ *
+ * @param name the tag's name without the leading {@code #}, in lower case.
+ */
+public record Tag(String name) {
+ /** Marks a word as a tag, both when the user types it and when Ted shows it. */
+ public static final String PREFIX = "#";
+
+ /**
+ * What a tag name may contain. Letters and digits only, so that a tag can
+ * never contain the spaces or {@code |} characters the save file relies on.
+ */
+ private static final Pattern VALID_NAME = Pattern.compile("[A-Za-z0-9]+");
+
+ /**
+ * Creates a tag with the given name, converted to lower case.
+ *
+ * @throws IllegalArgumentException if the name is empty or contains
+ * anything other than letters and digits.
+ */
+ public Tag {
+ if (!VALID_NAME.matcher(name).matches()) {
+ throw new IllegalArgumentException("A tag name must be letters and digits only: " + name);
+ }
+ name = name.toLowerCase(Locale.ROOT);
+ }
+
+ /**
+ * Returns whether the text is a tag as the user writes it: {@code #}
+ * followed by letters and digits, e.g. {@code #fun} or {@code #CS2103}.
+ *
+ * @param text a single word, e.g. from the user's input or the save file.
+ * @return {@code true} if {@link #fromText(String)} accepts the text.
+ */
+ public static boolean isValidText(String text) {
+ return text.startsWith(PREFIX) && VALID_NAME.matcher(text.substring(PREFIX.length())).matches();
+ }
+
+ /**
+ * Creates the tag written as the given text, e.g. {@code #Fun} gives {@code #fun}.
+ *
+ * @param text a tag as the user writes it, starting with {@code #}.
+ * @return the tag the text stands for.
+ * @throws IllegalArgumentException if {@link #isValidText(String)} rejects the text.
+ */
+ public static Tag fromText(String text) {
+ if (!isValidText(text)) {
+ throw new IllegalArgumentException("Not a tag: " + text);
+ }
+ return new Tag(text.substring(PREFIX.length()));
+ }
+
+ /**
+ * Returns the tag as the user sees it, e.g. {@code #fun}.
+ */
+ @Override
+ public String toString() {
+ return PREFIX + name;
+ }
+}
diff --git a/src/main/java/ted/task/Task.java b/src/main/java/ted/task/Task.java
index ef39529e2e..dcb4368710 100644
--- a/src/main/java/ted/task/Task.java
+++ b/src/main/java/ted/task/Task.java
@@ -1,6 +1,10 @@
package ted.task;
import java.time.format.DateTimeFormatter;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
/**
* A single task that Ted keeps track of.
@@ -26,6 +30,12 @@ public abstract class Task {
/** Whether the task has been completed. */
private boolean isDone;
+ /**
+ * Labels the user has attached to this task, in the order they were added.
+ * A set, so that attaching a tag the task already has changes nothing.
+ */
+ private final Set
* The line carries everything needed to rebuild this task: the type icon
- * ({@link #getTypeIcon()}), the done flag, the description, and any
- * type-specific detail. Todo uses the default implementation; subclasses
+ * ({@link #getTypeIcon()}), the done flag, any tags, the description, and
+ * any type-specific detail. Todo uses the default implementation; subclasses
* override it to pass their extra fields to {@link #toSaveLine(String...)}.
*
* @return one line of the save file, using {@code " | "} as the separator.
@@ -87,6 +126,10 @@ public String toSaveFormat() {
* deadline one, an event two -- so they are taken as varargs. Each caller
* then names its own fields in order and this method alone deals with
* escaping them and placing the separators.
+ *
+ * Tags, if there are any, form one field straight after the done flag, e.g.
+ * {@code T | 0 | #fun #school | read book}. A task without tags leaves the
+ * field out, so its line is exactly what Ted wrote before tags existed.
*
* @param extraFields type-specific fields, in the order they are saved,
* stored between the done flag and the description.
@@ -96,6 +139,10 @@ protected String toSaveLine(String... extraFields) {
StringBuilder line = new StringBuilder();
line.append(getTypeIcon()).append(SAVE_FIELD_SEPARATOR)
.append(isDone ? "1" : "0").append(SAVE_FIELD_SEPARATOR);
+ if (!tags.isEmpty()) {
+ // Tags need no escaping: after the # they are only letters and digits.
+ line.append(formatTags()).append(SAVE_FIELD_SEPARATOR);
+ }
for (String field : extraFields) {
line.append(encodeSaveField(field)).append(SAVE_FIELD_SEPARATOR);
}
@@ -123,13 +170,24 @@ protected static String encodeSaveField(String field) {
public abstract String getTypeIcon();
/**
- * Returns this task as it should appear to the user, e.g. {@code [T][X] read book}.
+ * Returns this task as it should appear to the user, e.g. {@code [T][X] read book #fun}.
* Overriding {@code toString} rather than writing a separate format method
* lets a task be printed directly wherever it is needed. Subclasses that
- * carry extra detail append it to this result.
+ * carry extra detail append it to this result, so a deadline's due date
+ * comes after its tags.
*/
@Override
public String toString() {
- return "[" + getTypeIcon() + "][" + getStatusIcon() + "] " + description;
+ String shown = "[" + getTypeIcon() + "][" + getStatusIcon() + "] " + description;
+ return tags.isEmpty() ? shown : shown + " " + formatTags();
+ }
+
+ /**
+ * Returns the tags the way they are both shown and saved, e.g. {@code #fun #school}.
+ */
+ private String formatTags() {
+ return tags.stream()
+ .map(Tag::toString)
+ .collect(Collectors.joining(" "));
}
}
diff --git a/src/main/java/ted/task/TaskList.java b/src/main/java/ted/task/TaskList.java
index dd3c4dae67..37cc8cdadb 100644
--- a/src/main/java/ted/task/TaskList.java
+++ b/src/main/java/ted/task/TaskList.java
@@ -99,6 +99,21 @@ public TaskList find(String keyword) {
.toList());
}
+ /**
+ * Returns the tasks that have the given tag.
+ *
+ * Only the exact tag counts, so {@code #fun} does not match a task tagged
+ * {@code #funny}: a tag is a label the user chose, not text to search inside.
+ *
+ * @param tag the tag to look for.
+ * @return the tasks with that tag, in the order they appear in this list.
+ */
+ public TaskList findByTag(Tag tag) {
+ return new TaskList(tasks.stream()
+ .filter(task -> task.hasTag(tag))
+ .toList());
+ }
+
/**
* Returns the tasks as a plain list, for code that only needs to read them.
*
diff --git a/src/test/java/ted/ParserTest.java b/src/test/java/ted/ParserTest.java
index 767a957703..c6d4ae4258 100644
--- a/src/test/java/ted/ParserTest.java
+++ b/src/test/java/ted/ParserTest.java
@@ -17,6 +17,7 @@
import ted.command.FindCommand;
import ted.command.ListCommand;
import ted.command.MarkCommand;
+import ted.command.TagCommand;
import ted.task.Task;
import ted.task.TaskList;
import ted.task.Todo;
@@ -218,6 +219,96 @@ public void parse_deleteWithNumber_indexIsZeroBased() throws TedException {
assertEquals(0, tasks.size());
}
+ @Test
+ public void parse_tagOrUntagWithTags_returnsTagCommand() throws TedException {
+ assertInstanceOf(TagCommand.class, Parser.parse("tag 1 #fun"));
+ assertInstanceOf(TagCommand.class, Parser.parse("untag 1 #fun #school"));
+ }
+
+ @Test
+ public void parse_tagThenExecute_tagsTheTaskInLowerCase() throws TedException {
+ TaskList tasks = new TaskList(List.of(new Todo("read book")));
+ // Extra spaces between the tags, and a repeated tag, make no difference.
+ Parser.parse("tag 1 #Fun #school #FUN").execute(tasks, new SilentUi(), new NoOpStorage());
+ assertEquals("[T][ ] read book #fun #school", tasks.get(0).toString());
+ }
+
+ @Test
+ public void parse_untagThenExecute_removesOnlyThoseTags() throws TedException {
+ TaskList tasks = new TaskList(List.of(new Todo("read book")));
+ Parser.parse("tag 1 #fun #school").execute(tasks, new SilentUi(), new NoOpStorage());
+ Parser.parse("untag 1 #school").execute(tasks, new SilentUi(), new NoOpStorage());
+ assertEquals("[T][ ] read book #fun", tasks.get(0).toString());
+ }
+
+ @Test
+ public void parse_tagWithoutTaskOrTags_exceptionThrown() {
+ TedException noArgument = assertThrows(TedException.class, () -> Parser.parse("tag"));
+ assertTrue(noArgument.getMessage().contains("tag 2 #fun"));
+ TedException noTags = assertThrows(TedException.class, () -> Parser.parse("untag 2"));
+ assertTrue(noTags.getMessage().contains("untag 2 #fun"));
+ }
+
+ @Test
+ public void parse_tagWithBadTaskNumber_exceptionThrown() {
+ TedException e = assertThrows(TedException.class, () -> Parser.parse("tag two #fun"));
+ assertTrue(e.getMessage().contains("\"two\" is not a task number"));
+ }
+
+ @Test
+ public void parse_tagWithInvalidTag_exceptionThrown() {
+ TedException e = assertThrows(TedException.class, () -> Parser.parse("tag 2 fun"));
+ assertTrue(e.getMessage().contains("\"fun\" is not a tag"));
+ assertThrows(TedException.class, () -> Parser.parse("tag 2 #"));
+ assertThrows(TedException.class, () -> Parser.parse("tag 2 #to-do"));
+ }
+
+ @Test
+ public void parse_untagTagTheTaskLacks_exceptionThrownAndTaskUnchanged() throws TedException {
+ TaskList tasks = new TaskList(List.of(new Todo("read book")));
+ Parser.parse("tag 1 #fun").execute(tasks, new SilentUi(), new NoOpStorage());
+
+ Command untag = Parser.parse("untag 1 #fun #nope");
+ Storage storage = new NoOpStorage();
+ TedException e = assertThrows(TedException.class, () -> untag.execute(tasks, new SilentUi(), storage));
+ assertTrue(e.getMessage().contains("doesn't have the tag #nope"));
+ // All or nothing: #fun must survive the rejected untag.
+ assertEquals("[T][ ] read book #fun", tasks.get(0).toString());
+ }
+
+ @Test
+ public void parse_tagTaskNumberPastEnd_exceptionThrown() throws TedException {
+ TaskList tasks = new TaskList(List.of(new Todo("read book")));
+ Command tag = Parser.parse("tag 9 #fun");
+ assertThrows(TedException.class, () -> tag.execute(tasks, new SilentUi(), new NoOpStorage()));
+ }
+
+ @Test
+ public void parse_findWithTag_showsOnlyTasksWithThatTag() throws TedException {
+ TaskList tasks = new TaskList(List.of(new Todo("read book"), new Todo("fun fair")));
+ Parser.parse("tag 1 #fun").execute(tasks, new SilentUi(), new NoOpStorage());
+
+ Ui ui = new SilentUi();
+ Parser.parse("find #FUN").execute(tasks, ui, new NoOpStorage());
+ String reply = ui.flush();
+ assertTrue(reply.contains("read book #fun"));
+ // "fun fair" contains the text "fun" but has no #fun tag.
+ assertFalse(reply.contains("fun fair"));
+ }
+
+ @Test
+ public void parse_findWithInvalidTag_exceptionThrown() {
+ TedException e = assertThrows(TedException.class, () -> Parser.parse("find #fun #school"));
+ assertTrue(e.getMessage().contains("find #fun"));
+ assertThrows(TedException.class, () -> Parser.parse("find #"));
+ }
+
+ @Test
+ public void parse_findWithHashLaterInKeyword_returnsFindCommand() throws TedException {
+ // Only a keyword that starts with # is a tag search.
+ assertInstanceOf(FindCommand.class, Parser.parse("find book #fun"));
+ }
+
/** A Ui that says nothing, so tests do not print over the test report. */
private static class SilentUi extends Ui {
@Override
diff --git a/src/test/java/ted/StorageTest.java b/src/test/java/ted/StorageTest.java
index 1a79a6dffe..5a739a8234 100644
--- a/src/test/java/ted/StorageTest.java
+++ b/src/test/java/ted/StorageTest.java
@@ -15,6 +15,7 @@
import ted.task.Deadline;
import ted.task.Event;
+import ted.task.Tag;
import ted.task.Task;
import ted.task.TaskList;
import ted.task.Todo;
@@ -151,4 +152,73 @@ public void load_fileHandEditedByUser_isAccepted(@TempDir Path tempDir)
assertEquals(1, loaded.size());
assertEquals("D | 1 | 2019-12-02T18:00 | return book", loaded.get(0).toSaveFormat());
}
+
+ @Test
+ public void saveThenLoad_taggedTasks_roundTripsUnchanged(@TempDir Path tempDir) throws TedException {
+ Storage storage = new Storage(tempDir.resolve("ted.txt").toString());
+ Todo todo = new Todo("read book");
+ todo.addTags(List.of(new Tag("fun"), new Tag("school")));
+ todo.markAsDone();
+ Deadline deadline = new Deadline("return book", SECOND_OF_DECEMBER_6PM);
+ deadline.addTags(List.of(new Tag("library")));
+ Event event = new Event("meeting", SECOND_OF_DECEMBER_4PM, SECOND_OF_DECEMBER_6PM);
+ event.addTags(List.of(new Tag("cs2103")));
+ TaskList saved = new TaskList(List.of(todo, deadline, event));
+
+ storage.save(saved);
+ List
+ * The parser, the save file and the tasks all rely on this rule, so a mistake
+ * here would show up in all three.
+ */
+public class TagTest {
+ @Test
+ public void constructor_mixedCaseName_storedInLowerCase() {
+ assertEquals("fun", new Tag("Fun").name());
+ assertEquals("#cs2103", new Tag("CS2103").toString());
+ }
+
+ @Test
+ public void equals_sameNameInDifferentCase_sameTag() {
+ // #Fun and #fun must be one tag, or a task could end up showing both.
+ assertEquals(new Tag("fun"), new Tag("FUN"));
+ }
+
+ @Test
+ public void constructor_nameWithOtherCharacters_exceptionThrown() {
+ assertThrows(IllegalArgumentException.class, () -> new Tag(""));
+ assertThrows(IllegalArgumentException.class, () -> new Tag("to-do"));
+ assertThrows(IllegalArgumentException.class, () -> new Tag("fun school"));
+ // A pipe inside a tag would break the save file's separators.
+ assertThrows(IllegalArgumentException.class, () -> new Tag("a|b"));
+ }
+
+ @Test
+ public void isValidText_hashThenLettersAndDigits_accepted() {
+ assertTrue(Tag.isValidText("#fun"));
+ assertTrue(Tag.isValidText("#CS2103"));
+ }
+
+ @Test
+ public void isValidText_otherText_rejected() {
+ assertFalse(Tag.isValidText("fun"));
+ assertFalse(Tag.isValidText("#"));
+ assertFalse(Tag.isValidText("##fun"));
+ assertFalse(Tag.isValidText("#to-do"));
+ assertFalse(Tag.isValidText("#fun #school"));
+ assertFalse(Tag.isValidText(""));
+ }
+
+ @Test
+ public void fromText_validText_returnsTagWithoutHash() {
+ assertEquals(new Tag("fun"), Tag.fromText("#Fun"));
+ }
+
+ @Test
+ public void fromText_invalidText_exceptionThrown() {
+ assertThrows(IllegalArgumentException.class, () -> Tag.fromText("fun"));
+ }
+}
diff --git a/src/test/java/ted/task/TaskListTest.java b/src/test/java/ted/task/TaskListTest.java
index daefa389b6..2566785f71 100644
--- a/src/test/java/ted/task/TaskListTest.java
+++ b/src/test/java/ted/task/TaskListTest.java
@@ -151,4 +151,26 @@ public void asList_returnedList_cannotBeModified() {
List
* T | 0 | borrow book
+ * T | 1 | #fun #school | read book
* D | 0 | 2019-06-06T18:00 | return book
- * E | 0 | 2019-08-06T14:00 | 2019-08-06T16:00 | project meeting
+ * E | 0 | #cs2103 | 2019-08-06T14:00 | 2019-08-06T16:00 | project meeting
*
*/
public class Storage {
@@ -130,7 +134,8 @@ public int getSkippedLineCount() {
/**
* Rebuilds a single task from one line of the data file.
*