From 619673c4e386651d1184bf0cc09a38713bd573df Mon Sep 17 00:00:00 2001 From: trigg770 Date: Thu, 10 Sep 2026 20:01:46 +0800 Subject: [PATCH 1/3] Let tasks carry tags and save them A task holds only a description, a done flag and, for deadlines and events, their dates. A user cannot label related tasks, e.g. all the tasks for one module, except by repeating the label in every description, where nothing treats it as a label. Let's give every task a set of tags such as #fun, shown after the description and saved as one extra field after the done flag. A task without tags keeps exactly the line it had before, and a field only counts as tags when every word in it is a valid tag, so existing save files load unchanged. A Tag record holds the rule for what a tag may be, letters and digits compared in lower case, so the tasks, the save file and later the parser share one definition. Allowing only letters and digits also keeps a tag from ever containing the spaces or pipes the save format relies on. Co-Authored-By: Claude Opus 5 --- src/main/java/ted/Storage.java | 79 ++++++++++++++++++++++------ src/main/java/ted/task/Tag.java | 73 +++++++++++++++++++++++++ src/main/java/ted/task/Task.java | 69 ++++++++++++++++++++++-- src/test/java/ted/StorageTest.java | 70 ++++++++++++++++++++++++ src/test/java/ted/task/TagTest.java | 63 ++++++++++++++++++++++ src/test/java/ted/task/TaskTest.java | 58 ++++++++++++++++++++ 6 files changed, 390 insertions(+), 22 deletions(-) create mode 100644 src/main/java/ted/task/Tag.java create mode 100644 src/test/java/ted/task/TagTest.java diff --git a/src/main/java/ted/Storage.java b/src/main/java/ted/Storage.java index b7628abe5d..dcd87b8381 100644 --- a/src/main/java/ted/Storage.java +++ b/src/main/java/ted/Storage.java @@ -6,11 +6,13 @@ import java.time.LocalDateTime; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.regex.Pattern; import ted.task.Deadline; import ted.task.Event; +import ted.task.Tag; import ted.task.Task; import ted.task.TaskList; import ted.task.Todo; @@ -23,12 +25,14 @@ * tests point Ted at a scratch file instead of the real one. A relative path * such as {@code data/ted.txt} is built with {@link Path}, so it works the same * on any computer and any operating system. Each task is one line, formatted - * by {@link Task#toSaveFormat()}: + * by {@link Task#toSaveFormat()}. A tagged task carries its tags as an extra + * field straight after the done flag: * *
  * 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 { @@ -131,7 +135,8 @@ public int getSkippedLineCount() { /** * Rebuilds a single task from one line of the data file. *

- * The line has the shape {@code | | | }. + * The line has the shape + * {@code | | [ |] | }. * Pipes and backslashes inside fields are escaped. The limited split also * preserves descriptions containing raw separators from save files created * before escaping was introduced. @@ -140,14 +145,24 @@ public int getSkippedLineCount() { * @return the rebuilt task, or {@code null} if the line is not in the expected format. */ private static Task parseLine(String line) { - // A first, unlimited split just to read the type icon safely: even a - // corrupted line must have at least its icon before anything can be parsed. - String icon = line.split(FIELD_SEPARATOR_REGEX)[0]; + // A first, unlimited split to read the type icon and look for a tags + // field: even a corrupted line must have at least its icon before + // anything can be parsed. + String[] rawFields = line.split(FIELD_SEPARATOR_REGEX); + String icon = rawFields[0]; int fieldCount = fieldCountFor(icon); if (fieldCount == -1) { return null; } + // A tagged task has one extra field, straight after the done flag. The + // field must also hold valid tags, so that an older line whose + // description contains a raw separator is still read as it always was. + boolean hasTags = rawFields.length > fieldCount && isTagsField(rawFields[2]); + if (hasTags) { + fieldCount++; + } + // A limited split keeps legacy raw separators inside the final description. String[] fields = line.split(FIELD_SEPARATOR_REGEX, fieldCount); if (fields.length != fieldCount) { @@ -159,19 +174,22 @@ private static Task parseLine(String line) { String description = decodeSaveField(fields[fieldCount - 1]); boolean isDone = fields[1].equals("1"); + List tags = hasTags ? parseSavedTags(fields[2]) : List.of(); + // The tags field, when present, pushes the date fields one place along. + int firstDateField = hasTags ? 3 : 2; try { switch (icon) { case "T": - return withDone(new Todo(description), isDone); + return withSavedState(new Todo(description), isDone, tags); case "D": - return withDone(new Deadline( - description, parseSavedDateTime(fields[2])), isDone); + return withSavedState(new Deadline( + description, parseSavedDateTime(fields[firstDateField])), isDone, tags); case "E": - return withDone(new Event( + return withSavedState(new Event( description, - parseSavedDateTime(fields[2]), - parseSavedDateTime(fields[3])), - isDone); + parseSavedDateTime(fields[firstDateField]), + parseSavedDateTime(fields[firstDateField + 1])), + isDone, tags); default: // Unreachable: fieldCountFor accepts only T, D and E. return null; @@ -197,8 +215,34 @@ private static LocalDateTime parseSavedDateTime(String field) { } /** - * Number of fields a valid save line has for a given task type: + * Returns whether a saved field holds tags: one or more tags separated by + * single spaces, e.g. {@code #fun #school}. + * + * @param field one field from the save file. + * @return {@code true} if every word in the field is a valid tag. + */ + private static boolean isTagsField(String field) { + // The -1 keeps empty strings from stray spaces, so they fail the check + // instead of being silently dropped. + return Arrays.stream(field.split(" ", -1)).allMatch(Tag::isValidText); + } + + /** + * Reads back the tags written by {@link Task#toSaveFormat()}. + * + * @param field a field that {@link #isTagsField(String)} accepts. + * @return the tags, in the order they were saved. + */ + private static List parseSavedTags(String field) { + return Arrays.stream(field.split(" ")) + .map(Tag::fromText) + .toList(); + } + + /** + * Number of fields a valid untagged save line has for a given task type: * the icon and the done flag, plus the date fields, plus the description. + * A tagged line has one more, for its tags. * * @return 3 for a todo, 4 for a deadline, 5 for an event, or -1 if the icon is unknown. */ @@ -238,13 +282,14 @@ private static String decodeSaveField(String field) { } /** - * Applies the saved done flag to a freshly created task, which always - * starts out undone. + * Applies the saved done flag and tags to a freshly created task, which + * always starts out undone and untagged. */ - private static Task withDone(Task task, boolean isDone) { + private static Task withSavedState(Task task, boolean isDone, List tags) { if (isDone) { task.markAsDone(); } + task.addTags(tags); return task; } } diff --git a/src/main/java/ted/task/Tag.java b/src/main/java/ted/task/Tag.java new file mode 100644 index 0000000000..0629daeb45 --- /dev/null +++ b/src/main/java/ted/task/Tag.java @@ -0,0 +1,73 @@ +package ted.task; + +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * A label the user attaches to a task, e.g. {@code #fun}, so that related + * tasks can be picked out of the list together. + *

+ * 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 67dc2d9c12..d601a19d2c 100644 --- a/src/main/java/ted/task/Task.java +++ b/src/main/java/ted/task/Task.java @@ -1,5 +1,10 @@ package ted.task; +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. * Bundles a task's description with its done status, so that the two can no @@ -20,6 +25,12 @@ public abstract class Task { /** Whether the task has been completed. */ protected 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 tags = new LinkedHashSet<>(); + /** * Creates a task that is not done yet. * @@ -50,12 +61,41 @@ public void markAsNotDone() { this.isDone = false; } + /** + * Attaches tags to this task. Tags it already has are left where they are, + * so no tag is ever shown twice. + * + * @param newTags the tags to attach. + */ + public void addTags(Collection newTags) { + tags.addAll(newTags); + } + + /** + * Detaches tags from this task. Tags it does not have are ignored. + * + * @param oldTags the tags to detach. + */ + public void removeTags(Collection oldTags) { + tags.removeAll(oldTags); + } + + /** + * Returns whether this task has the given tag. + * + * @param tag the tag to look for. + * @return {@code true} if the tag is attached to this task. + */ + public boolean hasTag(Tag tag) { + return tags.contains(tag); + } + /** * Converts this task into a single line of the save file format. *

* 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. @@ -72,6 +112,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. @@ -81,6 +125,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); } @@ -108,13 +156,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/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 loaded = storage.load(); + + assertEquals(3, loaded.size()); + assertEquals(0, storage.getSkippedLineCount()); + assertInstanceOf(Deadline.class, loaded.get(1)); + assertInstanceOf(Event.class, loaded.get(2)); + // The save format includes the tags, so matching it proves they came back. + for (int i = 0; i < loaded.size(); i++) { + assertEquals(saved.asList().get(i).toSaveFormat(), loaded.get(i).toSaveFormat()); + } + } + + @Test + public void load_linesWrittenBeforeTags_readAsBefore(@TempDir Path tempDir) + throws TedException, IOException { + // None of these lines has a tags field, so each must load exactly as it + // did before tags existed, even where the description looks like a tag. + Path dataFile = tempDir.resolve("ted.txt"); + Files.write(dataFile, List.of( + "T | 0 | #fun", + "T | 0 | rock | roll", + "D | 1 | 2019-12-02T18:00 | return book")); + + Storage storage = new Storage(dataFile.toString()); + List loaded = storage.load(); + + assertEquals(3, loaded.size()); + assertEquals(0, storage.getSkippedLineCount()); + assertEquals("T | 0 | #fun", loaded.get(0).toSaveFormat()); + assertEquals("[T][ ] rock | roll", loaded.get(1).toString()); + assertEquals("D | 1 | 2019-12-02T18:00 | return book", loaded.get(2).toSaveFormat()); + } + + @Test + public void load_tagsInCapitalsOrRepeated_loadedOnceInLowerCase(@TempDir Path tempDir) + throws TedException, IOException { + // A hand-edited file need not match what Ted writes itself. + Path dataFile = tempDir.resolve("ted.txt"); + Files.writeString(dataFile, "T | 0 | #Fun #fun #School | read book\n"); + + List loaded = new Storage(dataFile.toString()).load(); + assertEquals("T | 0 | #fun #school | read book", loaded.get(0).toSaveFormat()); + } + + @Test + public void load_fieldThatIsNotValidTags_keptInDescription(@TempDir Path tempDir) + throws TedException, IOException { + // "#to-do" is not a tag, so the line is read like an older line whose + // description holds an unescaped separator, rather than being skipped. + Path dataFile = tempDir.resolve("ted.txt"); + Files.writeString(dataFile, "T | 0 | #to-do | read book\n"); + + List loaded = new Storage(dataFile.toString()).load(); + assertEquals("[T][ ] #to-do | read book", loaded.get(0).toString()); + } } diff --git a/src/test/java/ted/task/TagTest.java b/src/test/java/ted/task/TagTest.java new file mode 100644 index 0000000000..b98ed019eb --- /dev/null +++ b/src/test/java/ted/task/TagTest.java @@ -0,0 +1,63 @@ +package ted.task; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link Tag}, which holds the one rule for what a tag may be. + *

+ * 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/TaskTest.java b/src/test/java/ted/task/TaskTest.java index f7257ceb73..05f4b7b898 100644 --- a/src/test/java/ted/task/TaskTest.java +++ b/src/test/java/ted/task/TaskTest.java @@ -1,9 +1,11 @@ package ted.task; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.Test; @@ -96,4 +98,60 @@ public void toSaveFormat_descriptionContainingSeparator_escapesIt() { public void toSaveFormat_descriptionContainingBackslash_escapesIt() { assertEquals("T | 0 | back\\\\slash", new Todo("back\\slash").toSaveFormat()); } + + @Test + public void toString_taggedTodo_showsTagsAfterDescription() { + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"), new Tag("school"))); + assertEquals("[T][ ] read book #fun #school", todo.toString()); + } + + @Test + public void toString_taggedDeadline_showsTagsBeforeDate() { + Deadline deadline = new Deadline("return book", SECOND_OF_DECEMBER_6PM); + deadline.addTags(List.of(new Tag("library"))); + assertTrue(deadline.toString().startsWith("[D][ ] return book #library (by: ")); + } + + @Test + public void addTags_tagAlreadyPresent_notRepeated() { + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"))); + todo.addTags(List.of(new Tag("FUN"), new Tag("school"))); + // The repeated tag keeps its original place rather than moving to the end. + assertEquals("[T][ ] read book #fun #school", todo.toString()); + } + + @Test + public void removeTags_someOfTheTags_removesOnlyThose() { + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"), new Tag("school"))); + todo.removeTags(List.of(new Tag("school"))); + assertTrue(todo.hasTag(new Tag("fun"))); + assertFalse(todo.hasTag(new Tag("school"))); + } + + @Test + public void toSaveFormat_taggedTodo_writesTagsAfterDoneFlag() { + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"), new Tag("school"))); + assertEquals("T | 0 | #fun #school | read book", todo.toSaveFormat()); + } + + @Test + public void toSaveFormat_taggedEvent_writesTagsBeforeDates() { + Event event = new Event("meeting", SECOND_OF_DECEMBER_4PM, SECOND_OF_DECEMBER_6PM); + event.addTags(List.of(new Tag("cs2103"))); + assertEquals("E | 0 | #cs2103 | 2019-12-02T16:00 | 2019-12-02T18:00 | meeting", + event.toSaveFormat()); + } + + @Test + public void toSaveFormat_lastTagRemoved_writesUntaggedLine() { + // Without tags the line must be exactly what Ted wrote before tags existed. + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"))); + todo.removeTags(List.of(new Tag("fun"))); + assertEquals("T | 0 | read book", todo.toSaveFormat()); + } } From 75a9c466b942c3e32d04b0ba13b4bff40d82968a Mon Sep 17 00:00:00 2001 From: trigg770 Date: Thu, 10 Sep 2026 20:06:19 +0800 Subject: [PATCH 2/3] Add tag and untag commands Tasks can hold tags, but no command attaches or removes them, so every task's tags stay empty. Let's add the commands tag and untag, which take a task number and one or more tags, e.g. tag 2 #fun #school. One TagCommand handles both, the way MarkCommand covers mark and unmark, since the two differ only in the direction of the change and the wording. An untag naming a tag the task does not have is rejected before anything changes, because it usually means a typo, and removing the other tags anyway would leave the task half changed. Tagging a task with a tag it already has is accepted, since the result is what the user asked for. Co-Authored-By: Claude Opus 5 --- src/main/java/ted/Parser.java | 59 ++++++++++++++++- src/main/java/ted/Ui.java | 11 ++++ src/main/java/ted/command/CommandType.java | 6 ++ src/main/java/ted/command/TagCommand.java | 77 ++++++++++++++++++++++ src/test/java/ted/ParserTest.java | 65 ++++++++++++++++++ 5 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 src/main/java/ted/command/TagCommand.java diff --git a/src/main/java/ted/Parser.java b/src/main/java/ted/Parser.java index 6e2ed48bde..67f0c9d83b 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 ted.command.AddCommand; import ted.command.Command; @@ -13,8 +15,10 @@ 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; /** @@ -22,9 +26,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 { /** @@ -69,6 +73,8 @@ public static Command parse(String input) throws TedException { case UNMARK -> new MarkCommand(parseTaskIndex(argument, CommandType.UNMARK), false); case DELETE -> new DeleteCommand(parseTaskIndex(argument, CommandType.DELETE)); case FIND -> new FindCommand(parseKeyword(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)); @@ -163,6 +169,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 # [#...]}. + * + * @param argument everything the user typed after the command word. + * @param commandType {@link CommandType#TAG} or {@link CommandType#UNTAG}, used in error messages. + * @param isAdding {@code true} to attach the tags, {@code false} to detach them. + * @return the command that changes the task's tags. + * @throws TedException if the task number or the tags are missing or unreadable. + */ + private static TagCommand parseTagCommand(String argument, CommandType commandType, boolean isAdding) + throws TedException { + String example = "for example: " + commandType.getKeyword() + " 2 #fun"; + requireNotBlank(argument, "Which task, and which tags? " + example); + + // Any run of spaces separates the words, so padding between tags does not matter. + String[] words = argument.split("\\s+"); + int index = parseTaskIndex(words[0], commandType); + if (words.length == 1) { + throw new TedException("Which tags? Start each one with #, " + example); + } + + // A loop rather than a stream, because parseTag throws a checked + // exception, which a lambda cannot pass on. + List tags = new ArrayList<>(); + for (int i = 1; i < words.length; i++) { + tags.add(parseTag(words[i], example)); + } + return new TagCommand(index, tags, isAdding); + } + /** * Converts a task number typed by the user into an index into the task list. * Whether the number points at a real task is checked by {@link ted.task.TaskList} @@ -205,6 +242,22 @@ private static LocalDateTime parseDateTime(String text, String example) throws T } } + /** + * Turns one tag typed by the user, e.g. {@code #Fun}, into a {@link Tag}. + * + * @param text one word as typed. + * @param example wording showing how the command is used, used in the error message. + * @return the tag the word stands for. + * @throws TedException if the word is not {@code #} followed by letters and digits. + */ + private static Tag parseTag(String text, String example) throws TedException { + if (!Tag.isValidText(text)) { + throw new TedException("\"" + text + "\" is not a tag. " + + "A tag is # followed by letters and digits, " + example); + } + return Tag.fromText(text); + } + /** * Rejects input the user left out. * diff --git a/src/main/java/ted/Ui.java b/src/main/java/ted/Ui.java index e22a3d8747..0af5284721 100644 --- a/src/main/java/ted/Ui.java +++ b/src/main/java/ted/Ui.java @@ -163,6 +163,17 @@ public void showMarked(Task task, boolean isDone) { " " + task); } + /** + * Confirms a change to a task's tags. + * + * @param task the task whose tags changed. + * @param isAdding {@code true} if tags were attached, {@code false} if detached. + */ + public void showTagged(Task task, boolean isAdding) { + show(isAdding ? "OK, I've tagged this task:" : "OK, I've untagged this task:", + " " + task); + } + /** * Prints every stored task as a numbered list, starting from 1. * diff --git a/src/main/java/ted/command/CommandType.java b/src/main/java/ted/command/CommandType.java index 07a7370a29..4448058d50 100644 --- a/src/main/java/ted/command/CommandType.java +++ b/src/main/java/ted/command/CommandType.java @@ -37,6 +37,12 @@ public enum CommandType { /** Shows only the tasks whose description contains a keyword. */ FIND("find"), + /** Attaches tags to a task. */ + TAG("tag"), + + /** Detaches tags from a task. */ + UNTAG("untag"), + /** Ends the conversation. */ BYE("bye"); 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 tags; + + /** {@code true} to attach the tags, {@code false} to detach them. */ + private final boolean isAdding; + + /** + * Creates a command that changes one task's tags. + * + * @param index zero-based position of the task. + * @param tags the tags to attach or detach. + * @param isAdding {@code true} to attach them, {@code false} to detach them. + */ + public TagCommand(int index, List tags, boolean isAdding) { + this.index = index; + this.tags = List.copyOf(tags); + this.isAdding = isAdding; + } + + /** + * Changes the task's tags, shows the result, and saves the list. + * + * @throws TedException if no task has that number, an untag names a tag the + * task does not have, or the list cannot be saved. + */ + @Override + public void execute(TaskList tasks, Ui ui, Storage storage) throws TedException { + Task task = tasks.get(index); + if (isAdding) { + task.addTags(tags); + } else { + requireAllPresent(task); + task.removeTags(tags); + } + + ui.showTagged(task, isAdding); + storage.save(tasks); + } + + /** + * Rejects an untag that names a tag the task does not have. Every tag is + * checked before any is removed, so a typo in one of them cannot leave the + * task half changed. + * + * @param task the task about to lose some of its tags. + * @throws TedException if the task is missing one of the tags. + */ + private void requireAllPresent(Task task) throws TedException { + for (Tag tag : tags) { + if (!task.hasTag(tag)) { + throw new TedException("Task " + (index + 1) + " doesn't have the tag " + tag + + ", so nothing was changed."); + } + } + } +} diff --git a/src/test/java/ted/ParserTest.java b/src/test/java/ted/ParserTest.java index c989582078..e9096eb50e 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; @@ -201,6 +202,70 @@ 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())); + } + /** A Ui that says nothing, so tests do not print over the test report. */ private static class SilentUi extends Ui { @Override From 299c2866968d79263f2c2d3c13996b72c452c574 Mon Sep 17 00:00:00 2001 From: trigg770 Date: Thu, 10 Sep 2026 20:09:02 +0800 Subject: [PATCH 3/3] Let find search by tag Tags now label tasks, but find searches descriptions only, so a tag cannot be used to pick out the tasks it labels. Let's treat a find keyword that starts with # as a tag, and show only the tasks that have exactly that tag, so #fun does not match #funny. Any other keyword searches descriptions as before. The tag search is its own FindByTagCommand rather than a branch inside FindCommand, because matching a whole tag is a different rule from finding text inside a description. Co-Authored-By: Claude Opus 5 --- src/main/java/ted/Parser.java | 21 +++++++++++- src/main/java/ted/command/CommandType.java | 2 +- .../java/ted/command/FindByTagCommand.java | 33 +++++++++++++++++++ src/main/java/ted/task/TaskList.java | 15 +++++++++ src/test/java/ted/ParserTest.java | 26 +++++++++++++++ src/test/java/ted/task/TaskListTest.java | 22 +++++++++++++ 6 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 src/main/java/ted/command/FindByTagCommand.java diff --git a/src/main/java/ted/Parser.java b/src/main/java/ted/Parser.java index 67f0c9d83b..fb3ba65e17 100644 --- a/src/main/java/ted/Parser.java +++ b/src/main/java/ted/Parser.java @@ -12,6 +12,7 @@ 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; @@ -72,7 +73,7 @@ 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)); @@ -82,6 +83,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. * diff --git a/src/main/java/ted/command/CommandType.java b/src/main/java/ted/command/CommandType.java index 4448058d50..1c60cc075a 100644 --- a/src/main/java/ted/command/CommandType.java +++ b/src/main/java/ted/command/CommandType.java @@ -34,7 +34,7 @@ public enum CommandType { /** Removes a task from the list. */ DELETE("delete"), - /** Shows only the tasks whose description contains a keyword. */ + /** Shows only the tasks whose description contains a keyword, or that have a tag. */ FIND("find"), /** Attaches tags to a task. */ diff --git a/src/main/java/ted/command/FindByTagCommand.java b/src/main/java/ted/command/FindByTagCommand.java new file mode 100644 index 0000000000..6b6975d002 --- /dev/null +++ b/src/main/java/ted/command/FindByTagCommand.java @@ -0,0 +1,33 @@ +package ted.command; + +import ted.Storage; +import ted.Ui; +import ted.task.Tag; +import ted.task.TaskList; + +/** + * Shows only the tasks that have a given tag. + *

+ * 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/task/TaskList.java b/src/main/java/ted/task/TaskList.java index d9f3a17753..8d56471c79 100644 --- a/src/main/java/ted/task/TaskList.java +++ b/src/main/java/ted/task/TaskList.java @@ -102,6 +102,21 @@ public TaskList find(String keyword) { return matches; } + /** + * 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 e9096eb50e..3f27348260 100644 --- a/src/test/java/ted/ParserTest.java +++ b/src/test/java/ted/ParserTest.java @@ -266,6 +266,32 @@ public void parse_tagTaskNumberPastEnd_exceptionThrown() throws TedException { 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/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 view = tasks.asList(); assertThrows(UnsupportedOperationException.class, () -> view.add(new Todo("sneaky"))); } + + @Test + public void findByTag_similarTags_returnsOnlyExactMatches() throws TedException { + Todo fun = new Todo("read book"); + fun.addTags(List.of(new Tag("fun"))); + Todo funny = new Todo("watch film"); + funny.addTags(List.of(new Tag("funny"))); + TaskList tasks = new TaskList(List.of(fun, funny, new Todo("buy milk"))); + + TaskList matches = tasks.findByTag(new Tag("fun")); + // #funny starts with "fun" but is a different tag, so it must not match. + assertEquals(1, matches.size()); + assertSame(fun, matches.get(0)); + } + + @Test + public void find_keywordOnlyInATag_noMatch() { + // find searches descriptions, and a tag is not part of the description. + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"))); + assertTrue(new TaskList(List.of(todo)).find("fun").isEmpty()); + } }