diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000000..c6b7ee2b1e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,50 @@ +name: Java CI + +on: [push, pull_request] + +jobs: + build: + strategy: + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.platform }} + + steps: + - name: Set up repository + uses: actions/checkout@master + + - name: Set up repository + uses: actions/checkout@master + with: + ref: master + + - name: Merge to master + run: git checkout --progress --force ${{ github.sha }} + + - name: Validate Gradle Wrapper + uses: gradle/wrapper-validation-action@v1 + + - name: Setup JDK 11 + uses: actions/setup-java@v1 + with: + java-version: '11' + java-package: jdk+fx + + - name: Build and check with Gradle + run: ./gradlew check + + - name: Perform IO redirection test (*NIX) + if: runner.os == 'Linux' + working-directory: ${{ github.workspace }}/text-ui-test + run: ./runtest.sh + + - name: Perform IO redirection test (MacOS) + if: always() && runner.os == 'macOS' + working-directory: ${{ github.workspace }}/text-ui-test + run: ./runtest.sh + + - name: Perform IO redirection test (Windows) + if: always() && runner.os == 'Windows' + working-directory: ${{ github.workspace }}/text-ui-test + shell: cmd + run: runtest.bat diff --git a/.gitignore b/.gitignore index f69985ef1f..0aad6ee8af 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,11 @@ src/main/resources/docs/ *.iml bin/ +# Testing files +anthea.txt +antheaNotes.txt /text-ui-test/ACTUAL.txt +/text-ui-test/ACTUAL1.txt text-ui-test/EXPECTED-UNIX.TXT +/text-ui-test/anthea.txt +/text-ui-test/antheaNotes.txt \ No newline at end of file diff --git a/README.md b/README.md index 8715d4d915..d2b690e1f9 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,3 @@ -# Duke project template +# Anthea - Chatbot Task Organiser -This is a project template for a greenfield Java project. It's named after the Java mascot _Duke_. Given below are instructions on how to use it. - -## Setting up in Intellij - -Prerequisites: JDK 11, update Intellij to the most recent version. - -1. Open Intellij (if you are not in the welcome screen, click `File` > `Close Project` to close the existing project first) -1. Open the project into Intellij as follows: - 1. Click `Open`. - 1. Select the project directory, and click `OK`. - 1. If there are any further prompts, accept the defaults. -1. Configure the project to use **JDK 11** (not other versions) as explained in [here](https://www.jetbrains.com/help/idea/sdk.html#set-up-jdk).
- In the same dialog, set the **Project language level** field to the `SDK default` option. -3. After that, locate the `src/main/java/Duke.java` file, right-click it, and choose `Run Duke.main()` (if the code editor is showing compile errors, try restarting the IDE). If the setup is correct, you should see something like the below as the output: - ``` - Hello from - ____ _ - | _ \ _ _| | _____ - | | | | | | | |/ / _ \ - | |_| | |_| | < __/ - |____/ \__,_|_|\_\___| - ``` +This is an implementation extending a project template for a greenfield Java project. It's named after the Java mascot _Duke_. \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000000..b0d88e2e39 --- /dev/null +++ b/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'java' + id 'application' + id 'com.github.johnrengelman.shadow' version '5.1.0' + id 'checkstyle' + id 'org.openjfx.javafxplugin' version '0.0.10' +} + +checkstyle { + toolVersion = '10.2' +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.5.0' + testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: '5.5.0' + + String javaFxVersion = '11' + + implementation group: 'org.openjfx', name: 'javafx-base', version: javaFxVersion, classifier: 'win' + implementation group: 'org.openjfx', name: 'javafx-base', version: javaFxVersion, classifier: 'mac' + implementation group: 'org.openjfx', name: 'javafx-base', version: javaFxVersion, classifier: 'linux' + implementation group: 'org.openjfx', name: 'javafx-controls', version: javaFxVersion, classifier: 'win' + implementation group: 'org.openjfx', name: 'javafx-controls', version: javaFxVersion, classifier: 'mac' + implementation group: 'org.openjfx', name: 'javafx-controls', version: javaFxVersion, classifier: 'linux' + implementation group: 'org.openjfx', name: 'javafx-fxml', version: javaFxVersion, classifier: 'win' + implementation group: 'org.openjfx', name: 'javafx-fxml', version: javaFxVersion, classifier: 'mac' + implementation group: 'org.openjfx', name: 'javafx-fxml', version: javaFxVersion, classifier: 'linux' + implementation group: 'org.openjfx', name: 'javafx-graphics', version: javaFxVersion, classifier: 'win' + implementation group: 'org.openjfx', name: 'javafx-graphics', version: javaFxVersion, classifier: 'mac' + implementation group: 'org.openjfx', name: 'javafx-graphics', version: javaFxVersion, classifier: 'linux' +} + +test { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed" + + showExceptions true + exceptionFormat "full" + showCauses true + showStackTraces true + showStandardStreams = false + } +} + +application { + mainClassName = "anthea.gui.Launcher" +} + +shadowJar { + archiveBaseName = "Anthea" + archiveClassifier = null +} + +run { + standardInput = System.in + enableAssertions = true +} diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000000..870acebdda --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,429 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml new file mode 100644 index 0000000000..dcaa1af3c3 --- /dev/null +++ b/config/checkstyle/suppressions.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 8077118ebe..f3c77494ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,29 +1,290 @@ # User Guide -## Features +## Quick Start -### Feature-ABC +Get the JAR file, and place it in the directory of your choice. Run the JAR and you are set! On Windows, you may double-click the JAR to run it. On Mac, you can run `java -jar ./Anthea.jar` to avoid issues with saving data. -Description of the feature. +Console mode is also provided. You may run `java -jar ./Anthea.jar console` in Windows if you prefer using the console. -### Feature-XYZ +## Features -Description of the feature. +### Chatbot + +Anthea is chatbot-styled, where the commands are with specified words. + +### Tasks/Notes + +You can track your tasks, be them events, todos or deadlines. You can add notes too! ## Usage -### `Keyword` - Describe action +### Task Commands + +#### Making tasks + +##### `deadline DESCRIPTION [/by TIME]` + +Adds a deadline to track that has to be done by a certain `TIME`. + +**Example of usage:** `deadline Rush the holiday homework... /by 8-9 2018` + +This would make the deadline: + +``` +Good luck with the deadline, here's the task: +[D][ ] Rush the holiday homework... (by: 08 Sep 2018 2359) +``` + +##### `event DESCRIPTION [/at TIME]` + +Adds an event to track that happens at a certain `TIME`. + +**Example of usage:** `event Prepare for the holiday season dinner /at 21st dec 2012` + +This would make the event: +``` +That's going to happen at some time later: +[E][ ] Prepare for the holiday season dinner (at: 21 Dec 2012 2359) +``` + +##### `todo DESCRIPTION` + +Adds a todo to track. -Describe the action and its outcome. +**Example of usage:** `todo Plan holiday dinner` -Example of usage: +This would make the todo: +``` +I've recorded this thing you need to do: +[T][ ] Plan holiday dinner +``` -`keyword (optional arguments)` +#### Modifying tasks -Expected outcome: +##### `delete INDEX` -Description of the outcome. +Delete task with index `INDEX`. +**Example of usage:** ``` -expected output +event Prepare for the holiday season dinner /at 21st dec 2012 +delete 1 +``` + +This would delete the task: ``` +It seems you didn't need this task anymore, so I removed it: +[E][ ] Prepare for the holiday season dinner (at: 21 Dec 2012 2359) +You have 0 tasks left. +``` + +##### `mark INDEX` + +Mark the task with index `INDEX` as completed. + +**Example of usage:** +``` +event Prepare for the holiday season dinner /at 21st dec 2012 +mark 1 +``` + +This will mark the task as done: +``` +Marked your task as done: +[E][X] Prepare for the holiday season dinner (at: 21 Dec 2012 2359) +``` + +##### `reschedule INDEX [/at TIME] [/by TIME]` + +Reschedule the task. If the task is a deadline, `/by TIME` is used to reschedule. If the task is an event, `/at TIME` is used to reschedule. If the task is a todo, it doesn't have a time, so it cannot be rescheduled. You cannot reschedule your notes. + +**Example of usage:** +``` +event Prepare for the holiday season dinner /at 21st dec 2012 +reschedule 1 /at 2012 20/12 +``` + +This would reschedule the task: +``` +I have rescheduled your task! +[E][ ] Prepare for the holiday season dinner (at: 20 Dec 2012 2359) +``` + +##### `unmark INDEX` + +Unmark the task with index `INDEX`. + +**Example of usage:** +``` +event Prepare for the holiday season dinner /at 21st dec 2012 +unmark 1 +``` + +This would unmark the task: +``` +Aw... it's not done yet: +| [E][ ] Prepare for the holiday season dinne. (at: 21 Dec 2012 2359) +``` + +#### Viewing tasks + +##### `find DESCRIPTION` + +Find all tasks matching `DESCRIPTION`. The number next to the task is preserved. + +**Example of usage:** +``` +event Prepare for the holiday season dinner /at 21st dec 2012 +find holiday +``` + +This would find these tasks: +``` +Here are the tasks that you might be looking for: +1.[E][X] Prepare for the holiday season dinner (at: 21 Dec 2012 2359) +``` + +##### `list` + +List all tasks. + +**Example of usage:** +``` +event Prepare for the holiday season dinner /at 21st dec 2012 +list +``` + +This would list all tasks: +``` +Here, your tasks: +1.[E][X] Prepare for the holiday season dinner (at: 20 Dec 2012 2359) +``` + +### Note commands + +##### `delete note INDEX` + +Delete note with index `INDEX`. + +**Example of usage:** +``` +note Delete this /content Something to forget. +delete note 1 +``` + +This would delete the note: +``` +I removed this note: +Delete this +``` + +##### `find notes DESCRIPTION` + +Find all notes where the title matches `DESCSRIPTION`. The number next to the note is preserved. + +**Example of usage:** +``` +note Find this /content Something to find. +find notes Find +``` + +This would find the note: +``` +These notes match your query: +1.Find this +``` + +##### `list notes` + +List all notes. + +**Example of usage:** +``` +note Find this /content Something to find. +list notes +``` + +This would list your notes: +``` +Here, your notes: +1.Find this +``` + +##### `note DESCRIPTION [/content CONTENT]` + +Adds a note titled `DESCRIPTION` with content `CONTENT`. + +**Example of usage:** +``` +note Find this /content Something to find. +``` + +This would add a note: +``` +Added your note about Find this. +``` + +##### `view note INDEX` + +View note with index `INDEX`. This shows the title and content. + +**Example of usage:** +``` +note Find this /content Something to find. +view note 1 +``` + +This would view the note: +``` +Here's the note: +Find this +Something to find. +``` + +### Other commands + +##### `bye` +Close the application. + +### Command Glossary + +`CONTENT`/`DESCRIPTION`: Any ASCII string works, but ensure none of the words start with a forward slash, else it would be interpreted as a command modifier. Example: `grocery list` is fine but `groceries meat /fish` is not. + +`INDEX`: Any number works, as long it is the index of a task/note. Deleting a task/note will not preserve the index of other tasks/note behind it. + +`TIME`: Any string which contains the following keywords would be interpreted as a time if Anthea can resolve the time. +* A time written as HH:MM (24 hours) or HH:MMam or HH:MMpm, or with a period instead of a colon. +* A day of the week, full or abbreviated. +* An ordinal, interpreted as the day of a month (e.g. 1st, 23rd). +* A month name, full or abbreviated. +* A date, written as D/M/Y or D-M-Y. +* A year, written as Y. + +Time resolution proceeds as follows: +* A time at or after the present is attempted to be found which matches all the keywords +* If no such time exists, a time before the present but after 1st Jan 1AD 00:00 +* If no such time exists, the string is left intact for the benefit of the user. + +### Command Summary + +|Command|Description| +|-|-| +|`bye`|Close the application.| +|`deadline DESCRIPTION [/by TIME]`|Adds a deadline.| +|`delete INDEX`|Delete task.| +|`delete note INDEX`|Delete note.| +|`event DESCRIPTION [/at TIME]`|Adds an event.| +|`find DESCRIPTION`|Find tasks.| +|`find notes DESCRIPTION`|Find notes.| +|`list`|List all tasks.| +|`list notes`|List all notes.| +|`todo DESCRIPTION`|Adds a todo to track.| +|`mark INDEX`|Mark task as completed.| +|`note DESCRIPTION [/content CONTENT]`|Adds a note.| +|`reschedule INDEX [/at TIME] [/by TIME]`|Reschedule the task.| +|`unmark INDEX`|Unmark task.| +|`view note INDEX`|View note.| + + +### Data +The data for the application is stored in `anthea.txt` and `antheaNotes.txt` in CSV using base64. They may be deleted to purge data, or backups may be made of them. diff --git a/docs/Ui.png b/docs/Ui.png new file mode 100644 index 0000000000..d6293dc2e6 Binary files /dev/null and b/docs/Ui.png differ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..f3d88b1c2f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..c14115d7e0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100755 index 0000000000..645f6ca315 --- /dev/null +++ b/gradlew @@ -0,0 +1,183 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" \ No newline at end of file diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000000..a14a3d051d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,103 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega \ No newline at end of file diff --git a/src/main/java/Duke.java b/src/main/java/Duke.java deleted file mode 100644 index 5d313334cc..0000000000 --- a/src/main/java/Duke.java +++ /dev/null @@ -1,10 +0,0 @@ -public class Duke { - public static void main(String[] args) { - String logo = " ____ _ \n" - + "| _ \\ _ _| | _____ \n" - + "| | | | | | | |/ / _ \\\n" - + "| |_| | |_| | < __/\n" - + "|____/ \\__,_|_|\\_\\___|\n"; - System.out.println("Hello from\n" + logo); - } -} diff --git a/src/main/java/anthea/Anthea.java b/src/main/java/anthea/Anthea.java new file mode 100644 index 0000000000..051dae291c --- /dev/null +++ b/src/main/java/anthea/Anthea.java @@ -0,0 +1,83 @@ +package anthea; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Optional; + +import anthea.note.NoteList; +import anthea.task.TaskList; + +/** + * The main method of the chatbot, as well as its startup and teardown. + */ +public class Anthea { + /** List of commands */ + private static ArrayList commands; + private static UiInterface ui = new ConsoleUi(); + + /** + * Sets the current UI. + * + * @param ui The current UI to use. + */ + public static void setUi(UiInterface ui) { + assert ui != null; + Anthea.ui = ui; + } + + /** + * Gets the current UI to interact with. + * + * @return UiInterface that helps display text to screen. + */ + public static UiInterface getUi() { + assert ui != null; + return ui; + } + + private static ChatbotResponse handleCommand(String command) { + Optional response = Optional.empty(); + for (CommandMatcher matcher : commands) { + response = response.or(() -> matcher.run(command)); + } + return response.orElse(new ChatbotResponse("I cannot figure out this command...")); + } + + /** + * Runs the chatbot execution. + * + * @param args Command line args which are not used. + */ + public static void main(String[] args) { + // initialization + ui.greet(); + TaskList.initializeTaskList(); + NoteList.initializeNoteList(); + commands = Parser.getCommands(); + BufferedReader input = new BufferedReader(ui.getReader()); + + // main application logic + boolean isStillRunning = true; + while (isStillRunning) { + String command; + try { + command = input.readLine(); + } catch (IOException ex) { + System.out.println("IOException in application logic - terminating"); + throw new RuntimeException(ex); + } + if (command.equals("bye")) { + isStillRunning = false; + } else { + ChatbotResponse response = handleCommand(command); + response.print(ui); // ensure response is printed + } + } + + // finalization + TaskList.finalizeTaskList(); + NoteList.finalizeNoteList(); + ui.leave(); + } +} diff --git a/src/main/java/anthea/ChatbotResponse.java b/src/main/java/anthea/ChatbotResponse.java new file mode 100644 index 0000000000..3201d7844e --- /dev/null +++ b/src/main/java/anthea/ChatbotResponse.java @@ -0,0 +1,46 @@ +package anthea; + +import java.util.function.Consumer; + +/** + * Stores an output to print once. + */ +public class ChatbotResponse { + private Consumer responsePrinter; + private boolean hasPrinted = false; + + /** + * Constructs a wrapper for the output, + * so it prints only once. + * + * @param output Output to print only once. + */ + public ChatbotResponse(String... output) { + responsePrinter = (ui) -> ui.printStyledMessage(output); + } + + /** + * Constructs a wrapper for the output, + * so it prints only once. + * + * @param printer Method to print only once. + */ + public ChatbotResponse(Consumer printer) { + assert printer != null; + this.responsePrinter = printer; + } + + /** + * Prints the output if not printed yet. + * + * @param ui User interface to use. + */ + public void print(UiInterface ui) { + assert ui != null; + if (hasPrinted) { + return; + } + responsePrinter.accept(ui); + hasPrinted = true; + } +} diff --git a/src/main/java/anthea/CommandMatcher.java b/src/main/java/anthea/CommandMatcher.java new file mode 100644 index 0000000000..68e34e9720 --- /dev/null +++ b/src/main/java/anthea/CommandMatcher.java @@ -0,0 +1,50 @@ +package anthea; + +import java.util.function.Function; +import java.util.function.Predicate; + +import anthea.exception.ChatbotExceptionFunction; + +/** + * This class serves as a way to abstract the idea of making a command + * as a matching process and an action. + */ +public class CommandMatcher extends StringMatcher { + + /** + * Constructs an object that handles checking and executing a command. + * + * @param shouldRunAction Predicate to check if the command should be run. + * @param action Action to run. + */ + public CommandMatcher(Predicate shouldRunAction, Function action) { + super(shouldRunAction, action); + assert shouldRunAction != null; + assert action != null; + } + + /** + * Constructs an object that handles checking and executing a command. + * + * @param shouldRunAction Predicate to check if the command should be run. + * @param action Action to run. + * @return Constructed CommandMatcher. + */ + public static CommandMatcher of(Predicate shouldRunAction, ChatbotExceptionFunction action) { + return new CommandMatcher(shouldRunAction, ChatbotExceptionFunction.toFunction(action)); + } + + /** + * Constructs an object that handles checking and executing a command. + * + * @param prefix Prefix of the command which is checked. + * @param action Action to run. + * @return Constructed CommandMatcher. + */ + public static CommandMatcher of(String prefix, ChatbotExceptionFunction action) { + assert prefix != null; + assert action != null; + return new CommandMatcher((cmd) -> cmd.strip().startsWith(prefix), + ChatbotExceptionFunction.toFunction(action)); + } +} diff --git a/src/main/java/anthea/Commands.java b/src/main/java/anthea/Commands.java new file mode 100644 index 0000000000..1d5a2cb6a6 --- /dev/null +++ b/src/main/java/anthea/Commands.java @@ -0,0 +1,244 @@ +package anthea; + +import java.util.List; + +import anthea.note.Note; +import anthea.note.NoteList; +import anthea.task.Deadline; +import anthea.task.Event; +import anthea.task.Task; +import anthea.task.TaskList; +import anthea.task.ToDo; + +/** + * Creates commands. + */ +public class Commands { + /** + * Creates the command to add deadlines. + * @return Command for adding deadlines. + */ + public static CommandMatcher getAddDeadlineCommand() { + return new PrefixCommandMatcher("deadline", (str, map) -> { + assert str != null; + assert map != null; + Task task = new Deadline(str, map.getOrDefault("by", "[unknown]")); + TaskList.getTaskList().add(task); + return new ChatbotResponse( + "Good luck with the deadline, here's the task:", + task.toString()); + }); + } + + /** + * Creates the command to add events. + * @return Command for adding events. + */ + public static CommandMatcher getAddEventCommand() { + return new PrefixCommandMatcher("event", (str, map) -> { + assert str != null; + assert map != null; + Task task = new Event(str, map.getOrDefault("at", "[unknown]")); + TaskList.getTaskList().add(task); + return new ChatbotResponse( + "That's going to happen at some time later:", + task.toString()); + }); + } + + /** + * Creates the command to add tasks. + * @return Command for adding tasks. + */ + public static CommandMatcher getAddToDoCommand() { + return new PrefixCommandMatcher("todo", (str, map) -> { + assert str != null; + assert map != null; + Task task = new ToDo(str); + TaskList.getTaskList().add(task); + return new ChatbotResponse( + "I've recorded this thing you need to do:", + task.toString()); + }); + } + + /** + * Creates the command to mark tasks as done. + * @return Command for marking tasks as done. + */ + public static CommandMatcher getMarkCommand() { + return PrefixCommandMatcher.of("mark", (str, map) -> { + assert str != null; + assert map != null; + Task task = TaskList.getTask(str); + task.markAsDone(); + return new ChatbotResponse( + "Marked your task as done:", + task.toString()); + }); + } + + /** + * Creates the command to unmark tasks as done. + * @return Command for unmarking tasks as done. + */ + public static CommandMatcher getUnmarkCommand() { + return PrefixCommandMatcher.of("unmark", (str, map) -> { + assert str != null; + assert map != null; + Task task = TaskList.getTask(str); + task.markAsNotDone(); + return new ChatbotResponse( + "Aw... it's not done yet:", + task.toString()); + }); + } + + /** + * Creates the command to delete tasks. + * @return Command for deleting tasks. + */ + public static CommandMatcher getDeleteCommand() { + return PrefixCommandMatcher.of("delete", (str, map) -> { + assert str != null; + assert map != null; + Task task = TaskList.getTask(str); + TaskList.getTaskList().remove(task); + return new ChatbotResponse( + "It seems you didn't need this task anymore, so I removed it:", + task.toString(), + String.format("You have %d tasks left.", TaskList.getTaskList().size())); + }); + } + + /** + * Creates the command to reschedule. + * @return Command for rescheduling. + */ + public static CommandMatcher getRescheduleCommand() { + return PrefixCommandMatcher.of("reschedule", (str, map) -> { + assert str != null; + assert map != null; + Task task = TaskList.getTask(str); + if (task instanceof ToDo) { + return new ChatbotResponse("That's a todo, it doesn't have a date."); + } + Task newTask; + if (task instanceof Event) { + if (!map.containsKey("at")) { + return new ChatbotResponse("Do specify /at for events."); + } + newTask = new Event(task.getDescription(), map.get("at"), task.isTaskDone()); + } else if (task instanceof Deadline) { + if (!map.containsKey("by")) { + return new ChatbotResponse("Do specify /by for deadlines."); + } + newTask = new Deadline(task.getDescription(), map.get("by"), task.isTaskDone()); + } else { + return new ChatbotResponse("This is a strange task - I don't recognise it."); + } + List tasks = TaskList.getTaskList(); + tasks.set(tasks.indexOf(task), newTask); + return new ChatbotResponse( + "I have rescheduled your task!", + newTask.toString()); + }); + } + + /** + * Creates the command to list tasks. + * @return Command for listing tasks. + */ + public static CommandMatcher getListTasksCommand() { + return new CommandMatcher(str -> str.equals("list"), (str) -> { + assert str != null; + List tasks = TaskList.getTaskList(); + return new ChatbotResponse( + Parser.listObjects("Here, your tasks:", tasks)); + }); + } + + /** + * Creates the command to find tasks. + * @return Command for finding tasks. + */ + public static CommandMatcher getFindTasksCommand() { + return new PrefixCommandMatcher("find", (str, map) -> { + assert str != null; + assert map != null; + List> tasks = TaskList.filterTasks(str); + return new ChatbotResponse( + Parser.listNumberedObjects("Here are the tasks that you might be looking for:", tasks)); + }); + } + + /** + * Creates the command to list notes. + * @return Command for listing notes. + */ + public static CommandMatcher getListNotesCommand() { + return new CommandMatcher(str -> str.equals("list notes"), (str) -> { + assert str != null; + List notes = NoteList.getNoteList(); + return new ChatbotResponse( + Parser.listObjects("Here, your notes:", notes)); + }); + } + + /** + * Creates the command to find notes. + * @return Command for finding notes. + */ + public static CommandMatcher getFindNotesCommand() { + return new PrefixCommandMatcher("find notes", (str, map) -> { + assert str != null; + assert map != null; + List> notes = NoteList.filterNotes(str); + return new ChatbotResponse( + Parser.listNumberedObjects("These notes match your query:", notes)); + }); + } + + /** + * Creates the command to view notes. + * @return Command for viewing notes. + */ + public static CommandMatcher getViewNoteCommand() { + return PrefixCommandMatcher.of("view note", (str, map) -> { + assert str != null; + assert map != null; + Note note = NoteList.getNote(str); + return new ChatbotResponse("Here's the note:", note.getTitle(), note.getContent()); + }); + } + + /** + * Creates the command to delete notes. + * @return Command for deleting notes. + */ + public static CommandMatcher getDeleteNoteCommand() { + return PrefixCommandMatcher.of("delete note", (str, map) -> { + assert str != null; + assert map != null; + Note note = NoteList.getNote(str); + NoteList.getNoteList().remove(note); + return new ChatbotResponse( + "I removed this note:", + note.toString()); + }); + } + + /** + * Creates the command to add notes. + * @return Command for add notes. + */ + public static CommandMatcher getAddNoteCommand() { + return PrefixCommandMatcher.of("note", (str, map) -> { + assert str != null; + assert map != null; + Note note = new Note(str, map.getOrDefault("content", "[EMPTY]")); + NoteList.getNoteList().add(note); + return new ChatbotResponse(String.format("Added your note about %s.", str)); + }); + } +} diff --git a/src/main/java/anthea/ConsoleUi.java b/src/main/java/anthea/ConsoleUi.java new file mode 100644 index 0000000000..d7e3bbb792 --- /dev/null +++ b/src/main/java/anthea/ConsoleUi.java @@ -0,0 +1,53 @@ +package anthea; + +import java.io.InputStreamReader; +import java.io.Reader; + +/** + * Handles console-based user interface. Used for sanity checks. + */ +public class ConsoleUi implements UiInterface { + private static final String UPPER_BAR = ",----------------------------------------------------------------"; + private static final String LOWER_BAR = "'----------------------------------------------------------------"; + private static final String LEFT_BAR = "| "; + + /** + * {@inheritDoc} + */ + @Override + public Reader getReader() { + return new InputStreamReader(System.in); + } + + /** + * {@inheritDoc} + */ + @Override + public void printStyledMessage(String... lines) { + System.out.println(UPPER_BAR); + for (String str : lines) { + assert str != null; + System.out.print(LEFT_BAR); + System.out.println(str); + } + System.out.println(LOWER_BAR); + } + + /** + * {@inheritDoc} + */ + @Override + public void greet() { + printStyledMessage("...where is this again?", + "Oh, hello, I didn't see you there - I'm Anthea, a chatbot...", + "...or at least that's what they told me."); + } + + /** + * {@inheritDoc} + */ + @Override + public void leave() { + printStyledMessage("It was nice to have you around, I'm going back to sleep..."); + } +} diff --git a/src/main/java/anthea/Pair.java b/src/main/java/anthea/Pair.java new file mode 100644 index 0000000000..aa4110923e --- /dev/null +++ b/src/main/java/anthea/Pair.java @@ -0,0 +1,41 @@ +package anthea; + +/** + * Stores a pair. + * + * @param Type of first member of pair. + * @param Type of second member of pair. + */ +public class Pair { + private U u; + private V v; + + /** + * Constructs a pair. + * + * @param u First member of the pair. + * @param v Second member of the pair. + */ + public Pair(U u, V v) { + this.u = u; + this.v = v; + } + + /** + * Gets the first member of the pair. + * + * @return First member of the pair. + */ + public U getFirst() { + return this.u; + } + + /** + * Gets the second member of the pair. + * + * @return Second member of the pair. + */ + public V getSecond() { + return this.v; + } +} diff --git a/src/main/java/anthea/ParsedDateTime.java b/src/main/java/anthea/ParsedDateTime.java new file mode 100644 index 0000000000..a7ade9d55e --- /dev/null +++ b/src/main/java/anthea/ParsedDateTime.java @@ -0,0 +1,722 @@ +package anthea; + +import java.time.DateTimeException; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.Month; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.Temporal; +import java.time.temporal.TemporalAdjuster; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Optional; +import java.util.function.Predicate; + +/** + * Helper class to parse date/time strings + */ +public class ParsedDateTime { + private static final DateTimeFormatter[] formatters = { + DateTimeFormatter.ofPattern("dd MMM yyyy HHmm"), + DateTimeFormatter.ofPattern("yyyy-MM-dd"), DateTimeFormatter.ofPattern("d/M/yyyy HHmm"), + DateTimeFormatter.BASIC_ISO_DATE, DateTimeFormatter.ISO_LOCAL_DATE, DateTimeFormatter.ISO_OFFSET_DATE, + DateTimeFormatter.ISO_DATE, DateTimeFormatter.ISO_LOCAL_TIME, DateTimeFormatter.ISO_OFFSET_TIME, + DateTimeFormatter.ISO_TIME, DateTimeFormatter.ISO_LOCAL_DATE_TIME, DateTimeFormatter.ISO_OFFSET_DATE_TIME, + DateTimeFormatter.ISO_ZONED_DATE_TIME, DateTimeFormatter.ISO_DATE_TIME, DateTimeFormatter.ISO_ORDINAL_DATE, + DateTimeFormatter.ISO_WEEK_DATE, DateTimeFormatter.ISO_INSTANT, DateTimeFormatter.RFC_1123_DATE_TIME }; + private static final ArrayList> naturalDateParsers; + private static final ArrayList> naturalDateLatestTimeParsers; + + private static final int BIG_NUMBER_OF_ITERATIONS = 1024; + private static final int HOURS_PER_DAY = 24; + private static final int MINUTES_PER_HOUR = 60; + private static final int DATE_29 = 29; + private static final int MONTH_FEB = 2; + + static { + naturalDateParsers = new ArrayList<>(); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "mon", "monday" }), + s -> getDayOfWeekTemporalAdjuster(DayOfWeek.MONDAY))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "tue", "tuesday" }), + s -> getDayOfWeekTemporalAdjuster(DayOfWeek.TUESDAY))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "wed", "wednesday" }), + s -> getDayOfWeekTemporalAdjuster(DayOfWeek.WEDNESDAY))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "thur", "thursday" }), + s -> getDayOfWeekTemporalAdjuster(DayOfWeek.THURSDAY))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "fri", "friday" }), + s -> getDayOfWeekTemporalAdjuster(DayOfWeek.FRIDAY))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "sat", "saturday" }), + s -> getDayOfWeekTemporalAdjuster(DayOfWeek.SATURDAY))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "sun", "sunday" }), + s -> getDayOfWeekTemporalAdjuster(DayOfWeek.SUNDAY))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "jan", "january" }), + s -> getMonthTemporalAdjuster(Month.JANUARY))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "feb", "february" }), + s -> getMonthTemporalAdjuster(Month.FEBRUARY))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "mar", "march" }), + s -> getMonthTemporalAdjuster(Month.MARCH))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "apr", "april" }), + s -> getMonthTemporalAdjuster(Month.APRIL))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "may" }), + s -> getMonthTemporalAdjuster(Month.MAY))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "jun", "june" }), + s -> getMonthTemporalAdjuster(Month.JUNE))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "jul", "july" }), + s -> getMonthTemporalAdjuster(Month.JULY))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "aug", "august" }), + s -> getMonthTemporalAdjuster(Month.AUGUST))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "sep", "september" }), + s -> getMonthTemporalAdjuster(Month.SEPTEMBER))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "oct", "october" }), + s -> getMonthTemporalAdjuster(Month.OCTOBER))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "nov", "november" }), + s -> getMonthTemporalAdjuster(Month.NOVEMBER))); + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(new String[]{ "dec", "december" }), + s -> getMonthTemporalAdjuster(Month.DECEMBER))); + String[] dates = new String[38]; + for (int i = 1; i <= 31; i++) { + // 1th included, this is a feature + dates[i - 1] = String.format("%dth", i); + } + // custom ordinals + dates[31] = "1st"; + dates[32] = "2nd"; + dates[33] = "3rd"; + dates[34] = "21st"; + dates[35] = "22nd"; + dates[36] = "23rd"; + dates[37] = "31st"; + naturalDateParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(dates), + s -> { + boolean isTwoDigit = '0' <= s.charAt(1) && s.charAt(1) <= '9'; + int date = Integer.parseInt(s.substring(0, 1)); + if (isTwoDigit) { + date = Integer.parseInt(s.substring(0, 2)); + } + return getDateTemporalAdjuster(date); + })); + Predicate isDate = s -> { + String[] parts = s.split("/"); + if (parts.length == 2 || parts.length == 3) { + for (String part : parts) { + if (!isNumeric(part)) { + return false; + } + } + return true; + } + parts = s.split("-"); + if (parts.length == 2 || parts.length == 3) { + for (String part : parts) { + if (!isNumeric(part)) { + return false; + } + } + return true; + } + return false; + }; + naturalDateParsers.add(new StringMatcher<>( + isDate, + s -> { + try { + int date = -1; + int month = -1; + int year = -1; + String[] parts = s.split("/"); + if (parts.length == 2 || parts.length == 3) { + date = Integer.parseInt(parts[0]); + month = Integer.parseInt(parts[1]); + if (parts.length == 3) { + year = Integer.parseInt(parts[2]); + } + } + parts = s.split("-"); + if (parts.length == 2 || parts.length == 3) { + date = Integer.parseInt(parts[0]); + month = Integer.parseInt(parts[1]); + if (parts.length == 3) { + year = Integer.parseInt(parts[2]); + } + } + if (year == -1) { + return getDateMonthTemporalAdjuster(date, month); + } + return getDateMonthYearTemporalAdjuster(date, month, year); + } catch (NumberFormatException ex) { + return temporal -> { + throw new DateTimeException("Parse error for date"); + }; + } + })); + naturalDateParsers.add(new StringMatcher<>( + ParsedDateTime::isNumeric, + s -> { + try { + int year = Integer.parseInt(s); + if (year < 1) { + return temporal -> { + throw new DateTimeException("No negative years"); + }; + } + return getYearTemporalAdjuster(year); + } catch (NumberFormatException ex) { + return temporal -> { + throw new DateTimeException("Parse error for year"); + }; + } + })); + Predicate isTime = s -> { + String[] parts = s.split("[:.]"); + if (parts.length != 2) { + return false; + } + if (!isNumeric(parts[0])) { + return false; + } + if (isNumeric(parts[1])) { + return true; + } + if (parts[1].length() < 3) { + return false; + } + if (!isNumeric(parts[1].substring(0, parts[1].length() - 2))) { + return false; + } + String suffix = parts[1].substring(parts[1].length() - 2) + .toLowerCase(); + return suffix.equals("am") || suffix.equals("pm"); + }; + naturalDateParsers.add(new StringMatcher<>( + isTime, + s -> { + try { + String[] parts = s.split("[:.]"); + int hour = Integer.parseInt(parts[0]); + int minute; + boolean isInvalidHour; + if (isNumeric(parts[1])) { + minute = Integer.parseInt(parts[1]); + isInvalidHour = hour < 0 || hour >= HOURS_PER_DAY; + } else { + minute = Integer.parseInt(parts[1].substring(0, parts[1].length() - 2)); + String suffix = parts[1].substring(parts[1].length() - 2) + .toLowerCase(); + isInvalidHour = hour <= 0 || hour > 12; + if (suffix.equals("am")) { + if (hour == 12) { + hour = 0; + } + } else if (suffix.equals("pm")) { + if (hour != 12) { + hour += 12; + } + } + } + boolean isInvalidMinute = minute < 0 || minute >= MINUTES_PER_HOUR; + if (isInvalidHour || isInvalidMinute) { + return temporal -> { + throw new DateTimeException("Invalid hour/minute for time"); + }; + } + return getTimeTemporalAdjuster(hour, minute); + } catch (NumberFormatException ex) { + return temporal -> { + throw new DateTimeException("Parse error for time"); + }; + } + })); + TemporalAdjuster lastTimeOfDay = temporal -> + LocalDateTime.from(temporal).withHour(23).withMinute(59).withSecond(0).withNano(0); + TemporalAdjuster lastTimeOfMonth = temporal -> + LocalDateTime.from(temporal).with(TemporalAdjusters.lastDayOfMonth()).with(lastTimeOfDay); + TemporalAdjuster lastTimeOfYear = temporal -> + LocalDateTime.from(temporal).with(TemporalAdjusters.lastDayOfYear()).with(lastTimeOfDay); + naturalDateLatestTimeParsers = new ArrayList<>(); + String[] weekdays = new String[]{ + "mon", "monday", "tue", "tuesday", "wed", "wednesday", "thur", "thursday", "fri", "friday", + "sat", "saturday", "sun", "sunday" }; + String[] months = new String[]{ + "jan", "january", "feb", "february", "mar", "march", + "apr", "april", "may", "jun", "june", + "jul", "july", "aug", "august", "sep", "september", + "oct", "october", "nov", "november", "dec", "december" }; + naturalDateLatestTimeParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(weekdays), + s -> lastTimeOfDay)); + naturalDateLatestTimeParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(months), + s -> lastTimeOfMonth)); + naturalDateLatestTimeParsers.add(new StringMatcher<>( + StringMatcher.getCaseInsensitiveMatcher(dates), + s -> lastTimeOfDay)); + naturalDateLatestTimeParsers.add(new StringMatcher<>( + isDate, s -> lastTimeOfDay)); + naturalDateLatestTimeParsers.add(new StringMatcher<>( + ParsedDateTime::isNumeric, s -> lastTimeOfYear)); + naturalDateLatestTimeParsers.add(new StringMatcher<>( + isTime, s -> t -> t)); + } + + private Optional parsedDateTime; + private String input; + + /** + * Constructs an object to handle if the date/time can be parsed. + * + * @param input String that may represent date/time. + */ + public ParsedDateTime(String input) { + assert input != null; + this.input = input; + parsedDateTime = Optional.empty(); + for (DateTimeFormatter formatter : formatters) { + try { + parsedDateTime = Optional.of(LocalDateTime.parse(input, formatter)); + break; + } catch (DateTimeParseException ex) { + // Just try another one + } + } + } + + /** + * Constructs an object that handles an already parsed date/time. + * + * @param parsed Already parsed date/time. + */ + public ParsedDateTime(LocalDateTime parsed) { + input = ""; + parsedDateTime = Optional.of(parsed); + } + + /** + * Finds the earliest time after now that matches the description of input. If no such + * time exists, it falls back on the earliest time before now. If no such time still + * exists, it just gives the input as a ParsedDateTime which stores a String. + * + * @param input Human-readable description of the time, which only contains valid + * descriptors. Valid descriptors include: + * * case-insensitive Mon-Sun/Monday-Sunday, + * * an ordinal which specifies a date of the month, e.g. 3rd or 31st, + * * two or three numbers separated by a slashes or dashes which denote + * a date, e.g. 3/7 for the next 3rd of July, 3/7/2012 for the 3rd + * of July on 2012, and 1-2 for the next first of February, + * * a single number, for a year, e.g. 2024 for year 2024, + * * case-insensitive month name, or the abbreviation of a month name, e.g. Jan, July + * * two numbers separated by colons or periods (with optional AM/am/PM/pm + * after it) for a time, e.g. 23:45, 03.16pm. + * @param isLatestTime If true, gives the latest time in the first block of time which matches + * the input description. Example: If today is not Monday, "Mon" gives the + * time as 23:59 on Monday if isLatestTime, else 00:00. + * @return ParsedDateTime of the input. If input is not readable as a time, it + * a ParsedDateTime instance that acts like a String. + */ + public static ParsedDateTime of(String input, boolean isLatestTime) { + String[] tokens = input.split(" "); + try { + ArrayList adjusters = getDateTimeAdjusters(naturalDateParsers, tokens); + ArrayList latestTimeAdjusters = getDateTimeAdjusters( + naturalDateLatestTimeParsers, tokens); + Optional result = Optional.empty(); + Temporal now = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES); + result = result.or(() -> findAfterTimeMatching(now, adjusters)); + result = result.or(() -> findBeforeTimeMatching(now, adjusters)); + return result.map(t -> { + if (!isLatestTime) { + return t; + } + return applyLatestTimeAdjusters(latestTimeAdjusters, t); + }) + .map(LocalDateTime::from) + .map(t -> new ParsedDateTime(t)) + .orElseGet(() -> new ParsedDateTime(input)); + } catch (IllegalArgumentException ex) { + // thrown by getNaturalDateParsers if not all tokens are valid + return new ParsedDateTime(input); + } + } + + /** + * Applies the latest time adjusters. Each adjuster gives the latest time the token would consider as part + * of the same length of time specified by the token, so this method finds the earliest time all the tokens + * would still consider as part of the same length of time. + * + * @param latestTimeAdjusters List of time adjusters which give the latest time considered. + * @param t The time which is specified by all tokens. + * @return Latest time considered by all tokens. + */ + private static LocalDateTime applyLatestTimeAdjusters(ArrayList latestTimeAdjusters, Temporal t) { + return latestTimeAdjusters.stream() + .map(adjuster -> LocalDateTime.from(t.with(adjuster))) + .min(LocalDateTime::compareTo) + .orElse(LocalDateTime.from(t)); + } + + /** + * Gets a time adjuster to set to the earliest time at or after the given time + * which is of the day of week specified. + * + * @param day Day of week. + * @return Time adjuster which sets to next or same time. + */ + private static TemporalAdjuster getDayOfWeekTemporalAdjuster(DayOfWeek day) { + return temporal -> { + if (DayOfWeek.from(LocalDateTime.from(temporal)).equals(day)) { + return temporal; + } + Temporal result = TemporalAdjusters.next(day).adjustInto(temporal); + return LocalDateTime.from(result).truncatedTo(ChronoUnit.DAYS); + }; + } + + /** + * Checks if a string consists of digits. + * + * @param s A String. + * @return true if consisting only of ASCII digits, false otherwise. + */ + private static boolean isNumeric(String s) { + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) < '0' || '9' < s.charAt(i)) { + return false; + } + } + return true; + } + + /** + * Gets a time adjuster to set to the earliest time at or after the given time + * which is of the month specified. + * + * @param month Month. + * @return Time adjuster which sets to next or same time. + */ + private static TemporalAdjuster getMonthTemporalAdjuster(Month month) { + return temporal -> { + if (Month.from(temporal).equals(month)) { + return temporal; + } + Temporal result = temporal; + while (!Month.from(result).equals(month)) { + result = TemporalAdjusters.firstDayOfNextMonth().adjustInto(result); + } + return LocalDateTime.from(result).truncatedTo(ChronoUnit.DAYS); + }; + } + + /** + * Gets a time adjuster to set to the earliest time at or after the given time + * which is of the date of the month specified. + * + * @param date Date of the month. + * @return Time adjuster which sets to next or same time. + */ + private static TemporalAdjuster getDateTemporalAdjuster(int date) { + assert 1 <= date && date <= 31; + return temporal -> { + int temporalDayOfMonth = temporal.get(ChronoField.DAY_OF_MONTH); + if (temporalDayOfMonth == date) { + return temporal; + } + Temporal result = temporal; + while (true) { + int resultDayOfMonth = result.get(ChronoField.DAY_OF_MONTH); + if (resultDayOfMonth == date) { + break; + } + if (resultDayOfMonth > date) { + result = result.with(TemporalAdjusters.firstDayOfNextMonth()); + resultDayOfMonth = 1; + } + result = LocalDateTime.from(result).plusDays(date - resultDayOfMonth); + } + LocalDateTime startOfDay = LocalDateTime.from(result).truncatedTo(ChronoUnit.DAYS); + return startOfDay; + }; + } + + /** + * Gets a time adjuster to set to the earliest time at or after the given time + * which is of the time specified. + * + * @param hour Hour of day. + * @param minute Minute of hour. + * @return Time adjuster which sets to next or same time. + */ + private static TemporalAdjuster getTimeTemporalAdjuster(int hour, int minute) { + boolean isValidHour = 0 <= hour && hour < HOURS_PER_DAY; + boolean isValidMinute = 0 <= minute && minute < MINUTES_PER_HOUR; + if (!isValidHour || !isValidMinute) { + return temporal -> { + throw new DateTimeException( + String.format("Invalid time: Hour %d, Minute %d", hour, minute)); + }; + } + int minuteOfDay = minute + hour * MINUTES_PER_HOUR; + return temporal -> { + if (temporal.get(ChronoField.MINUTE_OF_DAY) <= minuteOfDay) { + return temporal.with(ChronoField.MINUTE_OF_DAY, minuteOfDay); + } + return temporal.plus(1, ChronoUnit.DAYS) + .with(ChronoField.MINUTE_OF_DAY, minuteOfDay); + }; + } + + /** + * Gets a time adjuster to set to the earliest time at or after the given time + * which is of the date/month specified. + * + * @param date Date of month. + * @param month Month. + * @return Time adjuster which sets to next or same time. + */ + private static TemporalAdjuster getDateMonthTemporalAdjuster(int date, int month) { + boolean isValidDate = 1 <= date && date <= 31; + boolean isValidMonth = 1 <= month && month <= 12; + int[] daysInMonth = new int[]{ 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + boolean isValidCombination = isValidDate && isValidMonth && date <= daysInMonth[month]; + if (!isValidCombination) { + return temporal -> { + throw new DateTimeException( + String.format("Date does not exist: Date %d, Month %d", date, month)); + }; + } + return temporal -> { + int temporalDate = temporal.get(ChronoField.DAY_OF_MONTH); + int temporalMonth = temporal.get(ChronoField.MONTH_OF_YEAR); + boolean isDate = temporalDate == date; + boolean isMonth = temporalMonth == month; + if (isDate && isMonth) { + return temporal; + } + boolean isLaterOnSameMonth = temporalMonth == month && temporalDate > date; + int year = LocalDateTime.from(temporal).getYear(); + if (temporalMonth > month || isLaterOnSameMonth) { + year++; + } + if (date == DATE_29 && month == MONTH_FEB) { + year = leapYearNextOrSame(year); + } + return LocalDateTime.of(year, month, date, 0, 0, 0); + }; + } + + /** + * Gets a time adjuster to set to the earliest time at or after the given time + * which is of the date/month/year specified. + * + * @param date Date of month. + * @param month Month. + * @param year Year. + * @return Time adjuster which sets to next or same time. + */ + private static TemporalAdjuster getDateMonthYearTemporalAdjuster(int date, int month, int year) { + boolean isValidDate = 1 <= date && date <= 31; + boolean isValidMonth = 1 <= month && month <= 12; + boolean isValidYear = 1 <= year; + int[] daysInMonth = new int[]{ + 0, 31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + boolean isValidCombination = isValidYear && isValidDate && isValidMonth && date <= daysInMonth[month]; + if (!isValidCombination) { + return temporal -> { + throw new DateTimeException(String.format( + "Date does not exist: Date %d, Month %d, Year %d", date, month, year)); + }; + } + return temporal -> { + int temporalDate = temporal.get(ChronoField.DAY_OF_MONTH); + int temporalMonth = temporal.get(ChronoField.MONTH_OF_YEAR); + int temporalYear = temporal.get(ChronoField.YEAR); + boolean isDate = temporalDate == date; + boolean isMonth = temporalMonth == month; + boolean isYear = temporalYear == year; + if (isDate && isMonth && isYear) { + return temporal; + } + boolean isLaterOnSameYear = isYear && temporalMonth > month; + boolean isLaterOnSameMonth = isYear && isMonth && temporalDate > date; + if (temporalYear > year || isLaterOnSameYear || isLaterOnSameMonth) { + throw new DateTimeException(String.format( + "Later date does not exist: Date %d, Month %d, Year %d", date, month, year)); + } + return LocalDateTime.of(year, month, date, 0, 0, 0); + }; + } + + /** + * Gets a time adjuster to set to the earliest time at or after the given time + * which is of the year specified. + * + * @param year Year. + * @return Time adjuster which sets to next or same time. + */ + private static TemporalAdjuster getYearTemporalAdjuster(int year) { + boolean isValidYear = 1 <= year; + if (!isValidYear) { + return temporal -> { + throw new DateTimeException(String.format( + "Date does not exist:Year %d", year)); + }; + } + return temporal -> { + int temporalYear = temporal.get(ChronoField.YEAR); + boolean isYear = temporalYear == year; + if (isYear) { + return temporal; + } + if (temporalYear > year) { + throw new DateTimeException(String.format( + "Later date does not exist: Year %d", year)); + } + return LocalDateTime.of(year, 1, 1, 0, 0, 0); + }; + } + + /** + * Gets the next or same year which is a leap year. + * + * @param year Year. + * @return Next or same year which is a leap year. + */ + private static int leapYearNextOrSame(int year) { + while (!isLeapYear(year)) { + year++; + } + return year; + } + + /** + * Checks if the input year is a leap year. + * + * @param year Year. + * @return true if it is a leap year, false otherwise. + */ + private static boolean isLeapYear(int year) { + return year % 400 == 0 || (year % 4 == 0 && year % 100 != 0); + } + + /** + * Gets TemporalAdjusters which match the tokens available from the matchers. + * + * @param matchers StringMatchers which check the tokens to give TemporalAdjusters. + * @param tokens Array of String tokens. + * @return ArrayList of TemporalAdjusters. + * @throws IllegalArgumentException If some token is not recognized by the StringMatchers. + */ + private static ArrayList getDateTimeAdjusters( + ArrayList> matchers, String[] tokens) { + ArrayList adjusters = new ArrayList<>(); + for (String token : tokens) { + if (token.equals("")) { + continue; + } + adjusters.add(parseToken(matchers, token)); + } + return adjusters; + } + + /** + * Gets TemporalAdjusters which match the token available from the matchers. + * + * @param matchers StringMatchers which check the tokens to give TemporalAdjusters. + * @param token Array of String tokens. + * @return TemporalAdjuster determined by StringMatchers. + * @throws IllegalArgumentException If the token is not recognized by the StringMatchers. + */ + private static TemporalAdjuster parseToken(ArrayList> matchers, String token) { + Optional adjuster = Optional.empty(); + for (StringMatcher matcher : matchers) { + adjuster = adjuster.or(() -> matcher.run(token)); + } + return adjuster.orElseThrow(() -> new IllegalArgumentException("Unrecognised token")); + } + + /** + * Finds the time after the given time which is a fixed point of all the adjusters. + * Warning: Imperfect practical implementation which stops an infinite loop. + * + * @param time Starting time. + * @param adjusters Adjusters which find the next time that works according to their description. + * @return Time which satisfies all adjusters' descriptions. + */ + private static Optional findAfterTimeMatching(Temporal time, ArrayList adjusters) { + try { + Temporal result = time; + for (int i = 0; i < BIG_NUMBER_OF_ITERATIONS; i++) { + Temporal originalTime = result; + for (TemporalAdjuster adjuster : adjusters) { + result = adjuster.adjustInto(result); + } + if (originalTime.equals(result)) { + return Optional.of(result); + } + } + return Optional.empty(); + } catch (DateTimeException ex) { + return Optional.empty(); + } + } + + /** + * Finds the time before the given time which is a fixed point of all the adjusters. + * + * @param time Ending time. + * @param adjusters Adjusters which find the next time that works according to their description. + * @return Time which satisfies all adjusters' descriptions. + */ + private static Optional findBeforeTimeMatching(Temporal time, ArrayList adjusters) { + try { + Temporal result = LocalDateTime.of(1, 1, 1, 0, 0, 0); + LocalDateTime endingTime = LocalDateTime.from(time); + while (LocalDateTime.from(result).compareTo(endingTime) <= 0) { + Temporal originalTime = result; + for (TemporalAdjuster adjuster : adjusters) { + result = adjuster.adjustInto(result); + } + if (originalTime.equals(result)) { + return Optional.of(result); + } + } + return Optional.empty(); + } catch (DateTimeException ex) { + return Optional.empty(); + } + } + + /** + * Gets a nicely-formatted date. + * + * @return The date if it parses, else the original string. + */ + @Override + public String toString() { + return parsedDateTime.map((dateTime) -> { + assert dateTime != null; + return dateTime.format(DateTimeFormatter.ofPattern("dd MMM yyyy HHmm")); + }).orElse(input); + } +} diff --git a/src/main/java/anthea/Parser.java b/src/main/java/anthea/Parser.java new file mode 100644 index 0000000000..6c42239f35 --- /dev/null +++ b/src/main/java/anthea/Parser.java @@ -0,0 +1,140 @@ +package anthea; + +import java.util.ArrayList; +import java.util.List; + +/** + * Handles gathering commands. + */ +public class Parser { + + /** + * Lists objects in a String[]. + * + * @param response String that describes the objects. + * @param objects List of objects. + * @param Type of objects. + * @return String[] of objects. + */ + public static String[] listObjects(String response, List objects) { + assert response != null; + assert objects != null; + String[] output = new String[objects.size() + 1]; + output[0] = response; + for (int i = 0; i < objects.size(); i++) { + assert objects.get(i) != null; + output[i + 1] = (i + 1) + "." + objects.get(i).toString(); + } + return output; + } + + /** + * Lists numbered objects in a String[]. + * + * @param response String that describes the objects. + * @param objects List of numbered objects. + * @param Type of objects. + * @return String[] of objects. + */ + public static String[] listNumberedObjects(String response, List> objects) { + assert response != null; + assert objects != null; + String[] output = new String[objects.size() + 1]; + output[0] = response; + for (int i = 0; i < objects.size(); i++) { + Pair pair = objects.get(i); + assert pair != null; + assert pair.getFirst() != null; + assert pair.getSecond() != null; + output[i + 1] = (pair.getFirst() + 1) + "." + pair.getSecond().toString(); + } + return output; + } + + /** + * Gets the chatbot commands in an ArrayList. + * + * @return ArrayList of chatbot commands. + */ + public static ArrayList getCommands() { + ArrayList commands = new ArrayList<>(); + + // This has to be before since the commands are more specific. + commands = addNoteCommands(commands); + + commands = addTaskCommands(commands); + commands = addTaskModificationCommands(commands); + commands = addTaskViewingCommands(commands); + + commands = addCatchAllCommand(commands); + + return commands; + } + + /** + * Adds adding task commands to an ArrayList. + * + * @return ArrayList of chatbot commands. + */ + public static ArrayList addTaskCommands(ArrayList commands) { + assert commands != null; + commands.add(Commands.getAddDeadlineCommand()); + commands.add(Commands.getAddEventCommand()); + commands.add(Commands.getAddToDoCommand()); + return commands; + } + + /** + * Adds task modification commands to an ArrayList. + * + * @return ArrayList of chatbot commands. + */ + public static ArrayList addTaskModificationCommands(ArrayList commands) { + assert commands != null; + commands.add(Commands.getMarkCommand()); + commands.add(Commands.getUnmarkCommand()); + commands.add(Commands.getDeleteCommand()); + commands.add(Commands.getRescheduleCommand()); + return commands; + } + + /** + * Adds task viewing commands to an ArrayList. + * + * @return ArrayList of chatbot commands. + */ + public static ArrayList addTaskViewingCommands(ArrayList commands) { + assert commands != null; + commands.add(Commands.getListTasksCommand()); + commands.add(Commands.getFindTasksCommand()); + return commands; + } + + /** + * Adds note commands to an ArrayList. + * + * @return ArrayList of chatbot commands. + */ + public static ArrayList addNoteCommands(ArrayList commands) { + assert commands != null; + commands.add(Commands.getListNotesCommand()); + commands.add(Commands.getFindNotesCommand()); + commands.add(Commands.getViewNoteCommand()); + commands.add(Commands.getDeleteNoteCommand()); + commands.add(Commands.getAddNoteCommand()); + return commands; + } + + /** + * Adds the command that runs otherwise + * to an ArrayList. + * + * @return ArrayList of chatbot commands. + */ + public static ArrayList addCatchAllCommand(ArrayList commands) { + commands.add( + new CommandMatcher((str) -> true, (str) -> new ChatbotResponse( + "(>.<') I'm sorry, I don't really know what that means."))); + return commands; + } +} diff --git a/src/main/java/anthea/PrefixCommandMatcher.java b/src/main/java/anthea/PrefixCommandMatcher.java new file mode 100644 index 0000000000..fd2e4ecdbe --- /dev/null +++ b/src/main/java/anthea/PrefixCommandMatcher.java @@ -0,0 +1,80 @@ +package anthea; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Predicate; + +import anthea.exception.ChatbotException; +import anthea.exception.ChatbotExceptionBiFunction; +import anthea.exception.ChatbotExceptionFunction; + +/** + * Makes a command matcher based on prefix. + * It splits the slash options "/by /at" and other parts as a Map<String, String> + * and trims the string involved. + * The action takes the String and a map containing the options. + */ +public class PrefixCommandMatcher extends CommandMatcher { + /** + * Creates a command matcher that tries to match a prefix. + * + * @param prefix Prefix to match. + * @param action Action to do. + */ + public PrefixCommandMatcher(String prefix, BiFunction, ChatbotResponse> action) { + super(makePrefixMatcher(prefix), + ChatbotExceptionFunction.toFunction((cmd) -> { + // preprocessing + cmd = cmd.strip(); + + // corner case + if (cmd.equals(prefix)) { + throw new ChatbotException(new ChatbotResponse( + "(>.<') Add a description to your " + prefix + ".")); + } + + // map processing + String withoutPrefix = cmd.substring(prefix.length() + 1); + String[] commandParts = withoutPrefix.split(" /"); + Map map = new HashMap<>(); + for (int i = 1; i < commandParts.length; i++) { + String[] keyAndValue = commandParts[i].split(" ", 2); + if (keyAndValue.length == 2) { + map.put(keyAndValue[0].strip(), keyAndValue[1]); + } else { + map.put(keyAndValue[0].strip(), ""); + } + } + + // another corner case + if (commandParts[0].equals("")) { + throw new ChatbotException(new ChatbotResponse( + "(>.<') The description for " + prefix + " shouldn't be empty.")); + } + + // accept + return action.apply(commandParts[0], map); + })); + assert prefix != null; + assert action != null; + } + + private static Predicate makePrefixMatcher(String prefix) { + return (cmd) -> cmd.strip().startsWith(prefix + " ") || cmd.strip().equals(prefix); + } + + /** + * Creates a command matcher that tries to match a prefix. + * + * @param prefix Prefix to match. + * @param action Action to do. + */ + public static PrefixCommandMatcher of(String prefix, ChatbotExceptionBiFunction> action) { + assert prefix != null; + assert action != null; + return new PrefixCommandMatcher(prefix, + ChatbotExceptionBiFunction.toBiFunction(action)); + } +} diff --git a/src/main/java/anthea/Storage.java b/src/main/java/anthea/Storage.java new file mode 100644 index 0000000000..2e0bc45f9c --- /dev/null +++ b/src/main/java/anthea/Storage.java @@ -0,0 +1,102 @@ +package anthea; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Scanner; + + +/** + * Handles file state. + */ +public class Storage { + private static Map fileStates = new HashMap<>(); + private String contents = null; + private String fileName; + + private Storage(String fileName) { + this.fileName = fileName; + } + + /** + * Gets a singleton object that manages a particular file. + * + * @param fileName File name to identify file. + * @return Object managing the file. + */ + public static Storage getFileState(String fileName) { + if (!fileStates.containsKey(fileName)) { + fileStates.put(fileName, new Storage(fileName)); + } + return fileStates.get(fileName); + } + + private static String convertToBase64(String input) { + return Base64.getEncoder().encodeToString(input.getBytes(StandardCharsets.UTF_16)); + } + + private static String convertFromBase64(String input) { + return StandardCharsets.UTF_16.decode(ByteBuffer.wrap(Base64.getDecoder().decode(input))).toString(); + } + + /** + * Gets lines from file as a String[][]. + * + * @return Array of String[], each storing comma-separated parts of a line. + */ + public String[][] getLines() { + ArrayList lines = new ArrayList<>(); + try { + File f = new File(fileName); + Scanner sc = new Scanner(f); + while (sc.hasNext()) { + ArrayList curLine = new ArrayList<>(); + for (String str : sc.nextLine().split(",")) { + curLine.add(convertFromBase64(str)); + } + lines.add(curLine.toArray(new String[]{})); + } + return (String[][]) lines.toArray(new String[][]{}); + } catch (FileNotFoundException ex) { + // file not found or error + return new String[][] {}; + } + } + + /** + * Saves the string to the file on disk. + * + * @param strings Strings to save. + */ + public void saveLines(String[][] strings) { + assert strings != null; + try { + FileWriter writer = new FileWriter(fileName, false); + for (int i = 0; i < strings.length; i++) { + assert strings[i] != null; + if (i > 0) { + writer.append('\n'); + } + StringBuilder builder = new StringBuilder(); + for (String s : strings[i]) { + builder.append(',').append(convertToBase64(s)); + } + writer.append(builder.substring(1)); + } + writer.close(); + } catch (IOException ex) { + /*throw new ChatbotException(new ChatbotResponse( + "(>.<') I was unable to record your tasks...")));*/ + Anthea.getUi().printStyledMessage( + "(>.<') I was unable to record your tasks..."); + ex.printStackTrace(); + } + } +} diff --git a/src/main/java/anthea/StringMatcher.java b/src/main/java/anthea/StringMatcher.java new file mode 100644 index 0000000000..0e98fa035d --- /dev/null +++ b/src/main/java/anthea/StringMatcher.java @@ -0,0 +1,58 @@ +package anthea; + +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * This class serves as a way to abstract the idea of getting a result + * from a string. + */ +public class StringMatcher { + private Predicate isMatch; + private Function getResult; + + /** + * Constructs an object that calculates a particular case. + * + * @param isMatch Checks if this function handles this case. + * @param getResult Function that calculates result. + */ + public StringMatcher(Predicate isMatch, Function getResult) { + assert isMatch != null; + assert getResult != null; + this.isMatch = isMatch; + this.getResult = getResult; + } + + public static Predicate getCaseInsensitiveMatcher(String[] matches) { + String[] copy = new String[matches.length]; + for (int i = 0; i < matches.length; i++) { + copy[i] = matches[i].toLowerCase(); + } + return s -> { + String lowercase = s.toLowerCase(); + for (String candidate : copy) { + if (lowercase.equals(candidate)) { + return true; + } + } + return false; + }; + } + + /** + * Checks if the string matches. + * If it does, it would give the result. + * + * @param input String to check if it is for this command. + * @return An Optional of class T if the string matches. + */ + public Optional run(String input) { + assert input != null; + if (isMatch.test(input)) { + return Optional.of(getResult.apply(input)); + } + return Optional.empty(); + } +} diff --git a/src/main/java/anthea/UiInterface.java b/src/main/java/anthea/UiInterface.java new file mode 100644 index 0000000000..479519d75e --- /dev/null +++ b/src/main/java/anthea/UiInterface.java @@ -0,0 +1,32 @@ +package anthea; + +import java.io.Reader; + +/** + * Functions that a UI interface needs to implement. + */ +public interface UiInterface { + /** + * Gives you the input stream to scan to interact with this interface. + * + * @return InputStream to scan. + */ + Reader getReader(); + + /** + * Styles and prints lines with a border. + * + * @param lines Lines to be printed + */ + void printStyledMessage(String... lines); + + /** + * Greets user. + */ + void greet(); + + /** + * Leaves the user. + */ + void leave(); +} diff --git a/src/main/java/anthea/exception/ChatbotException.java b/src/main/java/anthea/exception/ChatbotException.java new file mode 100644 index 0000000000..e38a6c4cd8 --- /dev/null +++ b/src/main/java/anthea/exception/ChatbotException.java @@ -0,0 +1,32 @@ +package anthea.exception; + +import anthea.ChatbotResponse; + +/** + * An exception class that comes with a ChatbotResponse + * so that the chatbot can show the message to the screen. + */ +public class ChatbotException extends Exception { + private ChatbotResponse response; + + /** + * Constructs a ChatbotException such that it + * holds a ChatbotResponse. + * + * @param response Response which can be shown. + */ + public ChatbotException(ChatbotResponse response) { + super("[Exception]"); + assert response != null; + this.response = response; + } + + /** + * Gets the available response. + * + * @return Single-use response. + */ + public ChatbotResponse getResponse() { + return response; + } +} diff --git a/src/main/java/anthea/exception/ChatbotExceptionBiFunction.java b/src/main/java/anthea/exception/ChatbotExceptionBiFunction.java new file mode 100644 index 0000000000..3cbd1a0378 --- /dev/null +++ b/src/main/java/anthea/exception/ChatbotExceptionBiFunction.java @@ -0,0 +1,52 @@ +package anthea.exception; + +import java.util.function.BiFunction; + +import anthea.ChatbotResponse; + +/** + * Defines an interface for functions that throw ChatbotException. + * + * @param Input type. + * @param Input type. + */ +public interface ChatbotExceptionBiFunction { + /** + * Constructs ChatbotExceptionBiFunction from BiFunction. + * + * @param lambda BiFunction. + * @param Input type. + * @param Input type. + * @return Constructed ChatbotExceptionBiFunction. + */ + public static ChatbotExceptionBiFunction of(BiFunction lambda) { + assert lambda != null; + return lambda::apply; + } + + /** + * Constructs BiFunction from ChatbotExceptionBiFunction. + * + * @param lambda ChatbotExceptionFunction. + * @param Input type. + * @return Constructed Function. + */ + public static BiFunction toBiFunction(ChatbotExceptionBiFunction lambda) { + assert lambda != null; + return (a, b) -> { + try { + return lambda.apply(a, b); + } catch (ChatbotException exception) { + return exception.getResponse(); + } + }; + } + + /** + * Applies on T, U such that it allows for throwing of ChatbotException. + * + * @return Single-use output ChatbotResponse. + * @throws ChatbotException Single-use output ChatbotException. + */ + public ChatbotResponse apply(T t, U u) throws ChatbotException; +} diff --git a/src/main/java/anthea/exception/ChatbotExceptionFunction.java b/src/main/java/anthea/exception/ChatbotExceptionFunction.java new file mode 100644 index 0000000000..0f5a6fdc63 --- /dev/null +++ b/src/main/java/anthea/exception/ChatbotExceptionFunction.java @@ -0,0 +1,50 @@ +package anthea.exception; + +import java.util.function.Function; + +import anthea.ChatbotResponse; + +/** + * Defines an interface for functions that throw ChatbotException. + * + * @param Input type. + */ +public interface ChatbotExceptionFunction { + /** + * Constructs ChatbotExceptionFunction from Function. + * + * @param lambda Function. + * @param Input type. + * @return Constructed ChatbotExceptionFunction. + */ + public static ChatbotExceptionFunction of(Function lambda) { + assert lambda != null; + return a -> lambda.apply(a); + } + + /** + * Constructs Function from ChatbotExceptionFunction. + * + * @param lambda ChatbotExceptionFunction. + * @param Input type. + * @return Constructed Function. + */ + public static Function toFunction(ChatbotExceptionFunction lambda) { + assert lambda != null; + return a -> { + try { + return lambda.apply(a); + } catch (ChatbotException exception) { + return exception.getResponse(); + } + }; + } + + /** + * Applies on T such that it allows for throwing of ChatbotException. + * + * @return Single-use output ChatbotResponse. + * @throws ChatbotException Single-use output ChatbotException. + */ + public ChatbotResponse apply(T t) throws ChatbotException; +} diff --git a/src/main/java/anthea/gui/DialogBox.java b/src/main/java/anthea/gui/DialogBox.java new file mode 100644 index 0000000000..6e26f97951 --- /dev/null +++ b/src/main/java/anthea/gui/DialogBox.java @@ -0,0 +1,85 @@ +package anthea.gui; + +import java.io.IOException; +import java.util.Collections; + +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; + +//@@author clarence-chew-reused +// Reused from this tutorial +// https://se-education.org/guides/tutorials/javaFx.html +// with minor modifications at most + +/** + * An example of a custom control using FXML. + * This control represents a dialog box consisting of an ImageView to represent the speaker's face and a label + * containing text from the speaker. + */ +public class DialogBox extends HBox { + @FXML + private Label dialog; + @FXML + private ImageView displayPicture; + + private DialogBox(String text, Image img) { + try { + FXMLLoader fxmlLoader = new FXMLLoader(MainWindow.class.getResource("/view/DialogBox.fxml")); + fxmlLoader.setController(this); + fxmlLoader.setRoot(this); + fxmlLoader.load(); + } catch (IOException e) { + e.printStackTrace(); + } + + dialog.setText(text); + displayPicture.setImage(img); + setMinHeight(dialog.getPrefHeight()); + } + + /** + * Flips the dialog box such that the ImageView is on the left and text on the right. + */ + private void flip() { + ObservableList tmp = FXCollections.observableArrayList(this.getChildren()); + Collections.reverse(tmp); + getChildren().setAll(tmp); + setAlignment(Pos.TOP_LEFT); + } + + /** + * Gets a dialog box for the user. + * + * @param text User's text. + * @param img User's image. + * @return Dialog box. + */ + public static DialogBox getUserDialog(String text, Image img) { + assert text != null; + return new DialogBox(text, img); + } + + /** + * Gets a dialog box for Anthea. + * + * @param text Anthea's text. + * @param img Anthea's image. + * @return Dialog box. + */ + public static DialogBox getChatbotDialog(String text, Image img) { + assert text != null; + var db = new DialogBox(text, img); + db.flip(); + return db; + } +} + +//@@author diff --git a/src/main/java/anthea/gui/GraphicUi.java b/src/main/java/anthea/gui/GraphicUi.java new file mode 100644 index 0000000000..cb52a81524 --- /dev/null +++ b/src/main/java/anthea/gui/GraphicUi.java @@ -0,0 +1,89 @@ +package anthea.gui; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.PipedReader; +import java.io.PipedWriter; +import java.io.Reader; + +import anthea.UiInterface; + +//@@author clarence-chew-reused +// Reused from this tutorial +// https://se-education.org/guides/tutorials/javaFx.html +// with minor modifications at most + +/** + * Used for GUI. + */ +public class GraphicUi implements UiInterface { + private static final PipedWriter writer = new PipedWriter(); + private static final BufferedWriter bufferedWriter = new BufferedWriter(writer); + + /** + * Gets writer that writes out of application logic into GUI. + * + * @return Writer from application logic. + */ + public static PipedWriter getWriter() { + return writer; + } + + /** + * {@inheritDoc} + */ + @Override + public Reader getReader() { + try { + return new PipedReader(MainWindow.getWriter()); + } catch (IOException e) { + System.out.println("Did not obtain GUI reader - terminating"); + throw new RuntimeException(e); + } + } + + /** + * Prints lines. + * + * @param lines Line to be printed + */ + @Override + public void printStyledMessage(String... lines) { + StringBuilder result = new StringBuilder(); + for (String line : lines) { + assert line != null; + result.append(line); + result.append('\n'); + } + result.append('\n'); + try { + assert bufferedWriter != null; + bufferedWriter.write(result.toString(), 0, result.length()); + bufferedWriter.flush(); + } catch (IOException e) { + System.out.println("Cannot write output - terminating"); + throw new RuntimeException(e); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void greet() { + printStyledMessage("...where is this again?", + "Oh, hello, I didn't see you there - I'm Anthea, a chatbot...", + "...or at least that's what they told me."); + } + + /** + * {@inheritDoc} + */ + @Override + public void leave() { + printStyledMessage("It was nice to have you around, I'm going back to sleep...", + "(close the window yourself)"); + } +} + +//@@author diff --git a/src/main/java/anthea/gui/Launcher.java b/src/main/java/anthea/gui/Launcher.java new file mode 100644 index 0000000000..26ddb7e783 --- /dev/null +++ b/src/main/java/anthea/gui/Launcher.java @@ -0,0 +1,29 @@ +package anthea.gui; + +import anthea.Anthea; +import javafx.application.Application; + +//@@author clarence-chew-reused +// Reused from this tutorial +// https://se-education.org/guides/tutorials/javaFx.html +// with minor modifications at most + +/** + * A launcher class to workaround classpath issues. + */ +public class Launcher { + /** + * Launches the GUI application. + * + * @param args Ignored arguments. + */ + public static void main(String[] args) { + if (args.length > 0 && args[0].equals("console")) { + Anthea.main(new String[]{}); + return; + } + Application.launch(Main.class, args); + } +} + +//@@author diff --git a/src/main/java/anthea/gui/Main.java b/src/main/java/anthea/gui/Main.java new file mode 100644 index 0000000000..cc570c4f98 --- /dev/null +++ b/src/main/java/anthea/gui/Main.java @@ -0,0 +1,50 @@ +package anthea.gui; + +import java.io.IOException; + +import anthea.Anthea; +import javafx.application.Application; +import javafx.fxml.FXMLLoader; +import javafx.scene.Scene; +import javafx.scene.layout.AnchorPane; +import javafx.stage.Stage; + +//@@author clarence-chew-reused +// Reused from this tutorial +// https://se-education.org/guides/tutorials/javaFx.html +// with minor modifications at most + +/** + * A GUI for Anthea using FXML. + */ +public class Main extends Application { + /** + * Start the application and Anthea logic. + * + * @param stage JavaFX stage. + */ + @Override + public void start(Stage stage) { + try { + FXMLLoader fxmlLoader = new FXMLLoader(Main.class.getResource("/view/MainWindow.fxml")); + AnchorPane ap = fxmlLoader.load(); + Scene scene = new Scene(ap); + stage.setScene(scene); + // Start application logic in thread + Thread appLogic = new Thread("appLogic") { + @Override + public void run() { + Anthea.setUi(new GraphicUi()); + Anthea.main(new String[]{}); + } + }; + appLogic.start(); + fxmlLoader.getController().getOutput(); + stage.show(); + } catch (IOException e) { + e.printStackTrace(); + } + } +} + +//@@author diff --git a/src/main/java/anthea/gui/MainWindow.java b/src/main/java/anthea/gui/MainWindow.java new file mode 100644 index 0000000000..26ec6a3b91 --- /dev/null +++ b/src/main/java/anthea/gui/MainWindow.java @@ -0,0 +1,124 @@ +package anthea.gui; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.PipedReader; +import java.io.PipedWriter; + +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.image.Image; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.VBox; + +//@@author clarence-chew-reused +// Reused from this tutorial +// https://se-education.org/guides/tutorials/javaFx.html +// with minor modifications at most + +/** + * Controller for MainWindow. Provides the layout for the other controls. + */ +public class MainWindow extends AnchorPane { + /** + * GUI writes to here. + */ + private static final PipedWriter writer = new PipedWriter(); + + private static final BufferedWriter bufferedWriter = new BufferedWriter(writer); + /** + * GUI reads from here. + */ + private static BufferedReader reader; + + @FXML + private ScrollPane scrollPane; + @FXML + private VBox dialogContainer; + @FXML + private TextField userInput; + @FXML + private Button sendButton; + + private final Image userImage = new Image( + this.getClass().getResourceAsStream("/images/userImage.png"), + 96, 96, false, true); + private final Image antheaImage = new Image( + this.getClass().getResourceAsStream("/images/antheaImage.png"), + 96, 96, false, true); + + /** + * Gets the writer that writes out GUI interactions. + * + * @return The writer that writes out GUI interactions. + */ + public static PipedWriter getWriter() { + return writer; + } + + @FXML + private void initialize() { + try { + reader = new BufferedReader(new PipedReader(GraphicUi.getWriter())); + } catch (IOException e) { + System.out.println("Cannot initialize GUI output reader - terminating"); + throw new RuntimeException(e); + } + scrollPane.vvalueProperty().bind(dialogContainer.heightProperty()); + } + + /** + * Creates two dialog boxes, one echoing user input and the other containing Anthea's reply and then appends them to + * the dialog container. Clears the user input after processing. + */ + @FXML + private void handleUserInput() { + String input = userInput.getText(); + dialogContainer.getChildren().addAll( + DialogBox.getUserDialog(input, userImage) + ); + userInput.clear(); + try { + bufferedWriter.write(input); + bufferedWriter.newLine(); + bufferedWriter.flush(); + } catch (IOException ex) { + dialogContainer.getChildren().addAll( + DialogBox.getChatbotDialog(String.format( + "I did not manage to send that to application logic:\n%s", + input), antheaImage) + ); + } + getOutput(); + } + + /** + * Gets output. Used for first message too. + */ + public void getOutput() { + try { + StringBuilder result = new StringBuilder(); + while (true) { + String line = reader.readLine(); + if (line.length() == 0) { + break; + } + result.append(line); + result.append('\n'); + } + String responseWithoutTrailingNewline = result.substring(0, result.length() - 1); + dialogContainer.getChildren().addAll( + DialogBox.getChatbotDialog(responseWithoutTrailingNewline, antheaImage) + ); + } catch (IOException ex) { + dialogContainer.getChildren().addAll( + DialogBox.getChatbotDialog("I did not manage to read it...", antheaImage) + ); + } + } +} + +//@@author diff --git a/src/main/java/anthea/note/Note.java b/src/main/java/anthea/note/Note.java new file mode 100644 index 0000000000..977162ac79 --- /dev/null +++ b/src/main/java/anthea/note/Note.java @@ -0,0 +1,67 @@ +package anthea.note; + +/** + * Stores a note which has title and content. + */ +public class Note { + private String title; + private String content; + + /** + * Constructs a note. + * + * @param title Title of note. + * @param content Content of note. + */ + public Note(String title, String content) { + this.title = title; + this.content = content; + } + + /** + * Gets the title of the note. + * + * @return Title of the note. + */ + public String getTitle() { + return title; + } + + /** + * Gets the content of the note. + * + * @return Content of the note. + */ + public String getContent() { + return content; + } + + /** + * Checks if the note title matches the query. + * + * @param query Query for the note to check. + * @return True if the note description matches the query. + */ + public boolean isMatchingQuery(String query) { + return title.contains(query); + } + + /** + * Gets a String array format of the note to save to file. + * + * @return String array. + */ + public String[] getAsStringArray() { + return new String[]{ title, content }; + } + + /** + * Get String for printing to the screen. + * + * @return String for printing. + */ + @Override + public String toString() { + return title; + } +} diff --git a/src/main/java/anthea/note/NoteList.java b/src/main/java/anthea/note/NoteList.java new file mode 100644 index 0000000000..5454d95655 --- /dev/null +++ b/src/main/java/anthea/note/NoteList.java @@ -0,0 +1,76 @@ +package anthea.note; + +import java.util.ArrayList; +import java.util.List; + +import anthea.ChatbotResponse; +import anthea.Pair; +import anthea.exception.ChatbotException; + +/** + * Holds the list of tasks. + */ +public class NoteList { + /** List of tasks to remember */ + private static ArrayList noteList = new ArrayList<>(); + + /** + * Initializes the task list. + */ + public static void initializeNoteList() { + noteList = NoteStorage.getNotes(); + } + + /** + * Finalizes the note list. + */ + public static void finalizeNoteList() { + NoteStorage.saveNotes(noteList); + } + + /** + * Gets note from index as string. + * + * @param index Index as a string. + * @return Note if successful. + * @throws ChatbotException if not successful. + */ + public static Note getNote(String index) throws ChatbotException { + try { + int idx = Integer.parseInt(index); + return noteList.get(idx - 1); + } catch (NumberFormatException ex) { + throw new ChatbotException(new ChatbotResponse( + "Sorry, I didn't understand " + index + ", please give me a number.")); + } catch (IndexOutOfBoundsException ex) { + throw new ChatbotException(new ChatbotResponse( + "Sorry, the number " + index + ", wasn't in the range.")); + } + } + + /** + * Gets notes that match the search term. + * + * @param query Search term. + * @return List of indices and notes. + */ + public static ArrayList> filterNotes(String query) { + ArrayList> result = new ArrayList<>(); + for (int i = 0; i < noteList.size(); i++) { + Note note = noteList.get(i); + if (note.isMatchingQuery(query)) { + result.add(new Pair<>(i, note)); + } + } + return result; + } + + /** + * Gets the task list for other classes to work on. + * + * @return The task list. + */ + public static List getNoteList() { + return noteList; + } +} diff --git a/src/main/java/anthea/note/NoteStorage.java b/src/main/java/anthea/note/NoteStorage.java new file mode 100644 index 0000000000..99dc3d4679 --- /dev/null +++ b/src/main/java/anthea/note/NoteStorage.java @@ -0,0 +1,41 @@ +package anthea.note; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import anthea.Storage; + +/** + * Accesses a file for notes. + */ +public class NoteStorage { + + private static String noteStorageFileName = "./antheaNotes.txt"; + + /** + * Gets ArrayList of previously saved notes. + * + * @return ArrayList of notes. + */ + public static ArrayList getNotes() { + Storage storage = Storage.getFileState(noteStorageFileName); + ArrayList notes = new ArrayList<>(); + for (String[] line : storage.getLines()) { + assert line != null; + notes.add(new Note(line[0], line[1])); + } + return notes; + } + + /** + * Saves a list of notes to the default file. + * + * @param notes List of notes. + */ + public static void saveNotes(List notes) { + List lines = notes.stream().map(Note::getAsStringArray).collect(Collectors.toList()); + Storage storage = Storage.getFileState(noteStorageFileName); + storage.saveLines((String[][]) lines.toArray(new String[][]{})); + } +} diff --git a/src/main/java/anthea/task/Deadline.java b/src/main/java/anthea/task/Deadline.java new file mode 100644 index 0000000000..3cbf43f0c4 --- /dev/null +++ b/src/main/java/anthea/task/Deadline.java @@ -0,0 +1,51 @@ +package anthea.task; + +import anthea.ParsedDateTime; + +/** + * Handles a task with a deadline. + */ +public class Deadline extends Task { + protected ParsedDateTime datetime; + + /** + * Constructs a Deadline object. + * + * @param description Description of deadline. + * @param by Time of deadline. + */ + public Deadline(String description, String by) { + this(description, by, false); + } + + /** + * Constructs a Deadline object. + * + * @param description Description of deadline. + * @param by Time of deadline. + * @param isDone If the task is done. + */ + public Deadline(String description, String by, boolean isDone) { + super(description, isDone); + datetime = ParsedDateTime.of(by, true); + assert description != null; + assert by != null; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + return String.format("[D]%s (by: %s)", super.toString(), datetime.toString()); + } + + /** + * {@inheritDoc} + */ + @Override + public String[] getAsStringArray() { + String[] data = super.getAsStringArray(); + return new String[]{ "Deadline", data[1], data[2], datetime.toString() }; + } +} diff --git a/src/main/java/anthea/task/Event.java b/src/main/java/anthea/task/Event.java new file mode 100644 index 0000000000..68fad3d8ac --- /dev/null +++ b/src/main/java/anthea/task/Event.java @@ -0,0 +1,51 @@ +package anthea.task; + +import anthea.ParsedDateTime; + +/** + * Handles an event. + */ +public class Event extends Task { + protected ParsedDateTime datetime; + + /** + * Creates an event. + * + * @param description Description of event. + * @param at Time of event. + */ + public Event(String description, String at) { + this(description, at, false); + } + + /** + * Creates an event. + * + * @param description Description of event. + * @param at Time of event. + * @param isDone If the task is done. + */ + public Event(String description, String at, boolean isDone) { + super(description, isDone); + assert description != null; + assert at != null; + datetime = ParsedDateTime.of(at, true); + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + return String.format("[E]%s (at: %s)", super.toString(), datetime.toString()); + } + + /** + * {@inheritDoc} + */ + @Override + public String[] getAsStringArray() { + String[] data = super.getAsStringArray(); + return new String[]{ "Event", data[1], data[2], datetime.toString() }; + } +} diff --git a/src/main/java/anthea/task/Task.java b/src/main/java/anthea/task/Task.java new file mode 100644 index 0000000000..223363d762 --- /dev/null +++ b/src/main/java/anthea/task/Task.java @@ -0,0 +1,98 @@ +package anthea.task; + +/** + * A task stores its state and description. + */ +public class Task { + private static final char MARKER_DONE = 'X'; + private static final char MARKER_NOT_DONE = ' '; + private String name; + private boolean isDone; + + /** + * Constructs a task which is not done. + * + * @param name Name of task. + */ + public Task(String name) { + this(name, false); + assert name != null; + } + + /** + * Constructs a task. + * + * @param name Name of task. + * @param isDone True if and only if the task is done. + */ + public Task(String name, boolean isDone) { + assert name != null; + this.name = name; + this.isDone = isDone; + } + + /** + * Marks the task as done. + */ + public void markAsDone() { + isDone = true; + } + + /** + * Marks the task as not done. + */ + public void markAsNotDone() { + isDone = false; + } + + /** + * Checks if the task description matches the query. + * + * @param query Query for the task to check. + * @return True if the task description matches the query. + */ + public boolean isMatchingQuery(String query) { + return name.contains(query); + } + + private char getDoneMarker() { + return isDone ? MARKER_DONE : MARKER_NOT_DONE; + } + + /** + * Gets the task description. + * + * @return Task description. + */ + public String getDescription() { + return name; + } + + /** + * Gets if the task is done. + * + * @return true if the task is done, false otherwise. + */ + public boolean isTaskDone() { + return isDone; + } + + /** + * Creates a string representation suitable for printing to screen. + * + * @return String representation of task. + */ + @Override + public String toString() { + return String.format("[%c] %s", getDoneMarker(), name); + } + + /** + * Creates a string array representation suitable for printing to files. + * + * @return String array representation. + */ + public String[] getAsStringArray() { + return new String[]{ "Task", name, String.valueOf(isDone) }; + } +} diff --git a/src/main/java/anthea/task/TaskFactory.java b/src/main/java/anthea/task/TaskFactory.java new file mode 100644 index 0000000000..0f859ccdd5 --- /dev/null +++ b/src/main/java/anthea/task/TaskFactory.java @@ -0,0 +1,62 @@ +package anthea.task; + +import java.util.Optional; + +import anthea.Anthea; + +/** + * Constructs tasks from strings + */ +public class TaskFactory { + private static boolean isTrue(String str) { + return str.equals("true"); + } + + /** + * Constructs the task. + * + * @param taskData Data for the task. + * @return Task according to taskData. + * @throws IllegalArgumentException If taskData does not conform to the format. + */ + public static Task constructTask(String[] taskData) throws IllegalArgumentException { + if (taskData == null || taskData.length < 1) { + throw new IllegalArgumentException("taskData cannot be null or of length 1"); + } + boolean isTask = taskData[0].equals("Task") && taskData.length >= 3; + if (isTask) { + return new Task(taskData[1], isTrue(taskData[2])); + } + boolean isToDo = taskData[0].equals("ToDo") && taskData.length >= 3; + if (isToDo) { + return new ToDo(taskData[1], isTrue(taskData[2])); + } + boolean isDeadline = taskData[0].equals("Deadline") && taskData.length >= 4; + if (isDeadline) { + return new Deadline(taskData[1], taskData[3], isTrue(taskData[2])); + } + boolean isEvent = taskData[0].equals("Event") && taskData.length >= 4; + if (isEvent) { + return new Event(taskData[1], taskData[3], isTrue(taskData[2])); + } + throw new IllegalArgumentException("Unsupported task type or incorrect task data length"); + } + + /** + * Constructs the task. + * + * @param taskData Data for the task. + * @return Optional of Task according to taskData, Optional.empty() if cannot construct. + */ + public static Optional constructOptionalTask(String[] taskData) { + assert taskData != null; + try { + return Optional.of(constructTask(taskData)); + } catch (IllegalArgumentException ex) { + Anthea.getUi().printStyledMessage( + "(>.<') did not understand this task - dropping it", + String.join(", ", taskData)); + return Optional.empty(); + } + } +} diff --git a/src/main/java/anthea/task/TaskList.java b/src/main/java/anthea/task/TaskList.java new file mode 100644 index 0000000000..ac3c9864d4 --- /dev/null +++ b/src/main/java/anthea/task/TaskList.java @@ -0,0 +1,76 @@ +package anthea.task; + +import java.util.ArrayList; +import java.util.List; + +import anthea.ChatbotResponse; +import anthea.Pair; +import anthea.exception.ChatbotException; + +/** + * Holds the list of tasks + */ +public class TaskList { + /** List of tasks to remember */ + private static ArrayList taskList = new ArrayList<>(); + + /** + * Initializes the task list. + */ + public static void initializeTaskList() { + taskList = TaskStorage.getTasks(); + } + + /** + * Finalizes the task list. + */ + public static void finalizeTaskList() { + TaskStorage.saveTasks(taskList); + } + + /** + * Gets task from index as string. + * + * @param index Index as a string. + * @return Task if successful. + * @throws ChatbotException if not successful. + */ + public static Task getTask(String index) throws ChatbotException { + try { + int idx = Integer.parseInt(index); + return taskList.get(idx - 1); + } catch (NumberFormatException ex) { + throw new ChatbotException(new ChatbotResponse( + "Sorry, I didn't understand " + index + ", please give me a number.")); + } catch (IndexOutOfBoundsException ex) { + throw new ChatbotException(new ChatbotResponse( + "Sorry, the number " + index + ", wasn't in the range.")); + } + } + + /** + * Gets tasks that match the search term. + * + * @param query Search term. + * @return List of indices and tasks. + */ + public static ArrayList> filterTasks(String query) { + ArrayList> result = new ArrayList<>(); + for (int i = 0; i < taskList.size(); i++) { + Task task = taskList.get(i); + if (task.isMatchingQuery(query)) { + result.add(new Pair<>(i, task)); + } + } + return result; + } + + /** + * Gets the task list for other classes to work on. + * + * @return The task list. + */ + public static List getTaskList() { + return taskList; + } +} diff --git a/src/main/java/anthea/task/TaskStorage.java b/src/main/java/anthea/task/TaskStorage.java new file mode 100644 index 0000000000..843488c515 --- /dev/null +++ b/src/main/java/anthea/task/TaskStorage.java @@ -0,0 +1,41 @@ +package anthea.task; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import anthea.Storage; + +/** + * Accesses a file for tasks. + */ +public class TaskStorage { + + private static String taskStorageFileName = "./anthea.txt"; + + /** + * Gets ArrayList of previously saved tasks. + * + * @return ArrayList of tasks. + */ + public static ArrayList getTasks() { + Storage storage = Storage.getFileState(taskStorageFileName); + ArrayList tasks = new ArrayList<>(); + for (String[] line : storage.getLines()) { + assert line != null; + TaskFactory.constructOptionalTask(line).ifPresent((task) -> tasks.add(task)); + } + return tasks; + } + + /** + * Saves a list of tasks to the default file. + * + * @param tasks List of tasks. + */ + public static void saveTasks(List tasks) { + List lines = tasks.stream().map(Task::getAsStringArray).collect(Collectors.toList()); + Storage storage = Storage.getFileState(taskStorageFileName); + storage.saveLines((String[][]) lines.toArray(new String[][]{})); + } +} diff --git a/src/main/java/anthea/task/ToDo.java b/src/main/java/anthea/task/ToDo.java new file mode 100644 index 0000000000..32fd5940d5 --- /dev/null +++ b/src/main/java/anthea/task/ToDo.java @@ -0,0 +1,45 @@ +package anthea.task; + +/** + * A class that stores something to do. + */ +public class ToDo extends Task { + + /** + * Creates a task item. + * + * @param description Description of task. + */ + public ToDo(String description) { + this(description, false); + assert description != null; + } + + /** + * Creates a task item. + * + * @param description Description of task. + * @param isDone If the task is done. + */ + public ToDo(String description, boolean isDone) { + super(description, isDone); + assert description != null; + } + + @Override + public String toString() { + return String.format("[T]%s", super.toString()); + } + + /** + * Gets a string array representation suitable for printing to files. + * + * @return String array representation. + */ + @Override + public String[] getAsStringArray() { + String[] data = super.getAsStringArray(); + assert data != null; + return new String[]{ "ToDo", data[1], data[2] }; + } +} diff --git a/src/main/resources/images/antheaImage.png b/src/main/resources/images/antheaImage.png new file mode 100644 index 0000000000..771ef82dce Binary files /dev/null and b/src/main/resources/images/antheaImage.png differ diff --git a/src/main/resources/images/userImage.png b/src/main/resources/images/userImage.png new file mode 100644 index 0000000000..7f7edd8f1d Binary files /dev/null and b/src/main/resources/images/userImage.png differ diff --git a/src/main/resources/view/DialogBox.fxml b/src/main/resources/view/DialogBox.fxml new file mode 100644 index 0000000000..02cac458ee --- /dev/null +++ b/src/main/resources/view/DialogBox.fxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/src/main/resources/view/MainWindow.fxml b/src/main/resources/view/MainWindow.fxml new file mode 100644 index 0000000000..15c7040985 --- /dev/null +++ b/src/main/resources/view/MainWindow.fxml @@ -0,0 +1,19 @@ + + + + + + + + + + + +