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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 76 additions & 4 deletions src/main/java/ted/Parser.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -12,21 +14,24 @@
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;

/**
* Turns a line typed by the user into the {@link Command} it stands for.
* <p>
* 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 {
/**
Expand Down Expand Up @@ -72,14 +77,34 @@ 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));
case BYE -> new ExitCommand();
};
}

/**
* 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.
*
Expand Down Expand Up @@ -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 <task number> #<tag> [#<tag>...]}.
*
* @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<Tag> 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}
Expand Down Expand Up @@ -209,6 +265,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);
}

/**
* Returns where an option such as {@code /to} starts in the argument.
* Only a standalone word counts, so that the {@code /to} inside a
Expand Down
79 changes: 62 additions & 17 deletions src/main/java/ted/Storage.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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:
*
* <pre>
* 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
* </pre>
*/
public class Storage {
Expand Down Expand Up @@ -130,7 +134,8 @@ public int getSkippedLineCount() {
/**
* Rebuilds a single task from one line of the data file.
* <p>
* The line has the shape {@code <icon> | <done> | <date fields...> | <description>}.
* The line has the shape
* {@code <icon> | <done> | [<tags> |] <date fields...> | <description>}.
* Pipes and backslashes inside fields are escaped. The limited split also
* preserves descriptions containing raw separators from save files created
* before escaping was introduced.
Expand All @@ -141,14 +146,24 @@ public int getSkippedLineCount() {
private static Task parseLine(String line) {
assert !line.isBlank() : "load() skips blank lines, so they are never parsed";

// 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) {
Expand All @@ -160,19 +175,22 @@ private static Task parseLine(String line) {

String description = decodeSaveField(fields[fieldCount - 1]);
boolean isDone = fields[1].equals("1");
List<Tag> 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:
assert false : "unreachable, as fieldCountFor accepts only T, D and E";
return null;
Expand All @@ -198,8 +216,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<Tag> 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.
*/
Expand Down Expand Up @@ -239,13 +283,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<Tag> tags) {
if (isDone) {
task.markAsDone();
}
task.addTags(tags);
return task;
}
}
11 changes: 11 additions & 0 deletions src/main/java/ted/Ui.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
* Shows every stored task as a numbered list, starting from 1.
*
Expand Down
8 changes: 7 additions & 1 deletion src/main/java/ted/command/CommandType.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,15 @@ 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. */
TAG("tag"),

/** Detaches tags from a task. */
UNTAG("untag"),

/** Ends the conversation. */
BYE("bye");

Expand Down
33 changes: 33 additions & 0 deletions src/main/java/ted/command/FindByTagCommand.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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());
}
}
Loading