diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..fe2b9d862b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +max_line_length = 120 diff --git a/.gitignore b/.gitignore index 2873e189e1..4b6a9a0295 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,12 @@ -# IDEA files -/.idea/ -/out/ -/*.iml - -# Gradle build files -/.gradle/ -/build/ -src/main/resources/docs/ - -# MacOS custom attributes files created by Finder -.DS_Store +# Java/Gradle +.gradle/ +build/ +out/ +target/ +**/*.class +# IntelliJ +.idea/ *.iml -bin/ - -/text-ui-test/ACTUAL.TXT -text-ui-test/EXPECTED-UNIX.TXT +# OS +.DS_Store +Thumbs.db diff --git a/AI.md b/AI.md new file mode 100644 index 0000000000..cb184cd66e --- /dev/null +++ b/AI.md @@ -0,0 +1,17 @@ +# AI-Assisted Development Log + +This project uses AI tools to increase productivity while meeting iP requirements. + +## Tools Used +- ChatGPT (OpenAI): prompt-driven code assistance and review suggestions. + +## Usage Records + +### 2025-09-20 — C-Sort (Category C Extension) +- **What AI did:** Suggested a minimal insertion for a `sort` command inside the existing command switch in `src/main/java/quokka/Quokka.java`, keeping the codebase structure unchanged (no new classes). +- **Why helpful:** Reduced implementation time and ensured zero behavior drift elsewhere. +- **Developer review:** Verified that the change compiles and that the command behaves as intended (alphabetical, case-insensitive). + +## Notes +- AI suggestions are treated as drafts; final decisions and verifications are done by the developer. +- This file will be updated if/when more AI-assisted changes are adopted. diff --git a/README.md b/README.md index af0309a9ef..1cb1753185 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,34 @@ -# Duke project template - -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 17, 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 17** (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. -1. 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 - ____ _ - | _ \ _ _| | _____ - | | | | | | | |/ / _ \ - | |_| | |_| | < __/ - |____/ \__,_|_|\_\___| - ``` - -**Warning:** Keep the `src\main\java` folder as the root folder for Java files (i.e., don't rename those folders or move Java files to another folder outside of this folder path), as this is the default location some tools (e.g., Gradle) expect to find Java files. +# Quokka iP + +> “Your mind is for having ideas, not holding them.” — David Allen +> _(via_ [Getting Things Done](https://gettingthingsdone.com) _)_ + +Quokka is a text-based task manager that’s *simple*, **fast**, and ~~bug-prone~~ **robust** 😉. + +## Highlights +- Text UI you can use from any terminal +- Tasks: **Todo**, **Deadline**, **Event** +- Dates parsed as `yyyy-MM-dd` and displayed as `MMM d yyyy` +- Persistent storage in a human-editable file + +## Quick Start +1. Download the latest JAR from the **Releases** page. +2. Open a terminal in the folder containing the JAR. +3. Run: `java -jar "Duke.jar"`. +4. Add tasks, `list` them, then `bye`. + +## Sample Session +```java +// Commands you can try +todo read book +deadline return book /by 2019-10-15 +event project /from 2019-12-01 /to 2019-12-02 +list +bye +```` + +More info: + +* Main class: `quokka.Quokka` +* Releases: [Download Quokka.jar](https://github.com/WFYishere/ip/releases) + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000000..02f6770416 --- /dev/null +++ b/build.gradle @@ -0,0 +1,101 @@ +plugins { + id 'java' + id 'application' + id 'com.github.johnrengelman.shadow' version '7.1.2' + id 'checkstyle' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0' + + String javaFxVersion = '17.0.7' + + 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' +} + + +checkstyle { + toolVersion = '10.12.4' + maxWarnings = 0 + config = resources.text.fromString( + ''' + + + + + + ''' + ) +} + + +tasks.withType(Checkstyle).configureEach { + reports { + xml.required.set(false) + html.required.set(true) + } +} + +test { + useJUnitPlatform() + jvmArgs '-ea' + + testLogging { + events "passed", "skipped", "failed" + + showExceptions true + exceptionFormat "full" + showCauses true + showStackTraces true + showStandardStreams = false + } +} + +application { + mainClass.set('quokka.Launcher') + applicationDefaultJvmArgs = ['-ea'] +} + +jar { + manifest { + attributes('Main-Class': application.mainClass.get()) + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +shadowJar { + archiveFileName = 'Quokka.jar' + archiveClassifier = null + manifest { attributes('Main-Class': application.mainClass.get()) } + mergeServiceFiles() + exclude 'module-info.class' +} + +run{ + standardInput = System.in +} diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000000..ac7dd3ee41 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml new file mode 100644 index 0000000000..fd48dd0db8 --- /dev/null +++ b/config/checkstyle/suppressions.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/data/tasks.txt b/data/tasks.txt new file mode 100644 index 0000000000..c8c8d1eb29 --- /dev/null +++ b/data/tasks.txt @@ -0,0 +1,4 @@ +T | 0 | book +T | 0 | buy milk +D | 0 | submit assignment | 2025-09-30 +E | 0 | hackathon | 2025-09-10 | 2025-09-12 diff --git a/docs/README.md b/docs/README.md index 47b9f984f7..3266f2b5ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,30 +1,140 @@ -# Duke User Guide +# Quokka — User Guide -// Update the title above to match the actual product name +Welcome to **Quokka**, a friendly task assistant with a clean JavaFX UI and strong error handling. -// Product screenshot goes here +![UI](Ui.png) -// Product intro goes here +--- -## Adding deadlines +## Table of Contents +- [Quick Start](#quick-start) +- [Features](#features) + - [Add a Todo](#add-a-todo) + - [Add a Deadline](#add-a-deadline) + - [Add an Event](#add-an-event) + - [List Tasks](#list-tasks) + - [Find Tasks](#find-tasks) + - [Mark / Unmark / Delete](#mark--unmark--delete) + - [Help](#help) +- [Error Handling](#error-handling) +- [Data File](#data-file) +- [Keyboard Shortcuts](#keyboard-shortcuts) +- [FAQ](#faq) +- [Credits](#credits) -// Describe the action and its outcome. +--- -// Give examples of usage +## Quick Start +1. Ensure you have Java 17+ installed. +2. Build the fat JAR: +```bash + ./gradlew clean shadowJar +```` -Example: `keyword (optional arguments)` +3. Run: -// A description of the expected outcome goes here + ```bash + java -jar build/libs/Quokka.jar + ``` +4. Type a command in the input bar (bottom right). Press **Enter** or click **Send**. + +--- + +## Features + +### Add a Todo + +``` +todo +``` + +* Example: `todo read book` + +### Add a Deadline + +``` +deadline /by +``` + +* Example: `deadline submit report /by 2025-09-10` +* Dates are parsed strictly (e.g., **Feb 30** is rejected). + +### Add an Event + +``` +event /from /to +``` + +* Example: `event camp /from 2025-09-10 /to 2025-09-12` +* Start must be **strictly before** end. + +### List Tasks + +``` +list +``` + +### Find Tasks ``` -expected output +find ``` -## Feature ABC +* Example: `find book` + +### Mark / Unmark / Delete + +``` +mark +unmark +delete +``` + +### Help + +``` +help +``` + +--- + +## Error Handling + +Quokka aims to be forgiving and clear: + +* **Unknown command** → e.g., “Alas… I do not know this incantation: …” +* **Empty parts / missing flags** → e.g., missing `/by`, or `/from`…`/to`. +* **Invalid dates** → impossible calendar dates (e.g., `2025-02-30`) are rejected. +* **Event range** → start must be **before** end. +* **Duplicates** → adding an identical task warns without crashing. +* **Data file issues** → Quokka auto-creates the folder/file, **skips corrupted lines**, and continues running. + +--- + +## Data File + +* Location (default): `data/tasks.txt` +* Saves are **atomic** (write to temp, then move into place). + +--- + +## Keyboard Shortcuts + +* **Enter**: Send command +* **Ctrl+L**: Clear conversation area + +--- + +## FAQ + +**Q: What Java version do I need?** +A: Java 17+. -// Feature details +--- +## Credits -## Feature XYZ +* **Strict date parsing** uses `ResolverStyle.STRICT` as per Java Time (JSR-310) docs. +* JUnit 5 tests use standard APIs per the JUnit 5 user guide. -// Feature details \ No newline at end of file +*All other code authored by the project team. Additional third-party snippets will be listed here as needed.* diff --git a/docs/Ui.png b/docs/Ui.png new file mode 100644 index 0000000000..27f1fd4224 Binary files /dev/null and b/docs/Ui.png differ diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000000..005e033e2e --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-Dfile.encoding=UTF-8 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..033e24c4cd 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..66c01cfeba --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000000..fcb6fca147 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original 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 POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# 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 ;; #( + MSYS* | 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# 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"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000000..6689b85bee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@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=. +@rem This is normally unused +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% equ 0 goto execute + +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 execute + +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 + +: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 %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega 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/quokka/AppInfo.java b/src/main/java/quokka/AppInfo.java new file mode 100644 index 0000000000..f67d884f97 --- /dev/null +++ b/src/main/java/quokka/AppInfo.java @@ -0,0 +1,8 @@ +package quokka; + +/** Central place for product metadata shown in UI and logs. */ +public final class AppInfo { + public static final String PRODUCT_NAME = "Quokka"; + public static final String VERSION = "1.0.0"; + private AppInfo() {} +} diff --git a/src/main/java/quokka/Deadline.java b/src/main/java/quokka/Deadline.java new file mode 100644 index 0000000000..76a7de167c --- /dev/null +++ b/src/main/java/quokka/Deadline.java @@ -0,0 +1,38 @@ +package quokka; + +import quokka.util.Dates; + +import java.time.LocalDate; + +/** A deadline task that is due on a single date. */ +public class Deadline extends Task { + protected LocalDate by; + + public Deadline(String description, String by) { + super(description, TaskType.DEADLINE); + this.by = Dates.parseFlexibleDate(by); + assert this.by != null : "Deadline date must parse"; + } + + public Deadline(String description, String by, boolean isDone) { + super(description, TaskType.DEADLINE, isDone); + this.by = Dates.parseFlexibleDate(by); + assert this.by != null : "Deadline date must parse"; + } + + /** Expose the due date for searches/filters. */ + public LocalDate getByDate() { + return by; + } + + @Override + public String toString() { + return super.toString() + " (by: " + Dates.fmt(by) + ")"; + } + + @Override + public String toDataString() { + return TaskType.DEADLINE.getLabel() + " | " + (isDone ? "1" : "0") + + " | " + description + " | " + by; + } +} diff --git a/src/main/java/quokka/DukeException.java b/src/main/java/quokka/DukeException.java new file mode 100644 index 0000000000..423aefe4dd --- /dev/null +++ b/src/main/java/quokka/DukeException.java @@ -0,0 +1,7 @@ +package quokka; + +public class DukeException extends Exception { + public DukeException(String message) { + super(message); + } +} diff --git a/src/main/java/quokka/Event.java b/src/main/java/quokka/Event.java new file mode 100644 index 0000000000..8fdeda74bb --- /dev/null +++ b/src/main/java/quokka/Event.java @@ -0,0 +1,43 @@ +/** A concrete task type. */ + +package quokka; + +import java.time.LocalDate; +import quokka.util.Dates; + +public class Event extends Task { + protected LocalDate from; + protected LocalDate to; + + public Event(String description, String from, String to) { + super(description, TaskType.EVENT); + this.from = Dates.parseStrictDate(from); + this.to = Dates.parseStrictDate(to); + if (!this.from.isBefore(this.to)) { + throw new IllegalArgumentException("Event start must be strictly before end."); + } + assert this.from != null && this.to != null : "Event dates must parse"; + } + + + public Event(String description, String from, String to, boolean isDone) { + super(description, TaskType.EVENT, isDone); + this.from = Dates.parseFlexibleDate(from); + this.to = Dates.parseFlexibleDate(to); + assert this.from != null && this.to != null : "Event dates must parse"; + assert !this.from.isAfter(this.to) : "Event: start date must be <= end date"; + } + + + @Override + public String toString() { + return super.toString() + " (from: " + Dates.fmt(from) + " to: " + Dates.fmt(to) + ")"; + } + + + @Override + public String toDataString() { + return TaskType.EVENT.getLabel() + " | " + (isDone ? "1" : "0") + + " | " + description + " | " + from + " | " + to; + } +} diff --git a/src/main/java/quokka/Launcher.java b/src/main/java/quokka/Launcher.java new file mode 100644 index 0000000000..b66ba86ca5 --- /dev/null +++ b/src/main/java/quokka/Launcher.java @@ -0,0 +1,10 @@ +package quokka; + +import javafx.application.Application; + +/** Launches the JavaFX app to work around classpath issues. */ +public class Launcher { + public static void main(String[] args) { + Application.launch(Main.class, args); + } +} diff --git a/src/main/java/quokka/Main.java b/src/main/java/quokka/Main.java new file mode 100644 index 0000000000..a4461ef5c2 --- /dev/null +++ b/src/main/java/quokka/Main.java @@ -0,0 +1,244 @@ +package quokka; + +import javafx.application.Application; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +import java.util.Objects; + +/** + * JavaFX entry point for the Quokka GUI. + * Asymmetric chat layout, centered column, avatars, error highlighting, responsive resizing. + */ +public class Main extends Application { + + private Quokka quokka; + + private VBox dialog; + private ScrollPane scroller; + private VBox lane; + + private Image botAvatar; + private Image userAvatar; + + @Override + public void start(Stage stage) { + // ----- Root ----- + BorderPane root = new BorderPane(); + root.getStyleClass().add("hk-pane"); + root.setPadding(new Insets(12)); + + // ----- Header ----- + HBox headerBar = new HBox(10); + headerBar.getStyleClass().add("hk-headerbar"); + + ImageView titleIcon = loadIconView("/images/knight.png", 22); + Label header = new Label(AppInfo.PRODUCT_NAME); + header.getStyleClass().add("hk-header"); + headerBar.getChildren().addAll( + titleIcon != null ? titleIcon : new Label(), header + ); + root.setTop(headerBar); + + // ----- Dialog Area (centered column) ----- + dialog = new VBox(10); + dialog.setFillWidth(true); + + lane = new VBox(dialog); + lane.setAlignment(Pos.TOP_CENTER); + lane.setPadding(new Insets(8, 12, 12, 12)); + lane.setMaxWidth(720); + lane.getStyleClass().add("lane"); + + StackPane content = new StackPane(lane); + StackPane.setAlignment(lane, Pos.TOP_CENTER); + + scroller = new ScrollPane(content); + scroller.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + content.setStyle("-fx-background-color: transparent;"); + lane.setStyle("-fx-background-color: transparent;"); + dialog.setStyle("-fx-background-color: transparent;"); + + scroller.setFitToWidth(true); + scroller.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroller.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + scroller.setPannable(true); + + // Auto-scroll to bottom on new messages + dialog.heightProperty().addListener((obs, oldVal, newVal) -> scroller.setVvalue(1.0)); + + root.setCenter(scroller); + + // ----- Input Bar ----- + TextField input = new TextField(); + input.setPromptText("Type a command…"); + input.getStyleClass().add("hk-input"); + + Button send = new Button("Send"); + send.getStyleClass().add("hk-send"); + send.setDefaultButton(true); // Enter triggers + + HBox inputBar = new HBox(10, input, send); + inputBar.getStyleClass().add("input-bar"); + inputBar.setPadding(new Insets(10, 12, 8, 12)); + HBox.setHgrow(input, Priority.ALWAYS); + root.setBottom(inputBar); + + // ----- Handlers ----- + send.setOnAction(e -> { + String cmd = input.getText() == null ? "" : input.getText().trim(); + if (cmd.isEmpty()) return; + + addUser(cmd); + + Reply r = quokka.process(cmd); + addBot(r.message, r.error); + + input.clear(); + if (r.exit) stage.close(); + }); + input.setOnAction(send.getOnAction()); + + // Ctrl+L: clear chat + root.setOnKeyPressed(ev -> { + switch (ev.getCode()) { + case L: + if (ev.isControlDown()) { + dialog.getChildren().clear(); + addBot("Cleared.", false); + } + break; + default: + } + }); + + // ----- Scene / Stage ----- + Scene scene = new Scene(root, 560, 720); + scene.getStylesheets().add( + Objects.requireNonNull( + getClass().getResource("/quokka/view/hollowknight.css") + ).toExternalForm() + ); + + stage.setTitle(AppInfo.PRODUCT_NAME); + stage.setMinWidth(420); + stage.setMinHeight(540); + stage.setResizable(true); + stage.setScene(scene); + + // Load icon + avatars from classpath (works in fat JAR) + Image botIcon = loadImage("/images/knight.png"); + Image userIcon = loadImage("/images/user.png"); + + if (botIcon != null) { + stage.getIcons().add(botIcon); + botAvatar = botIcon; + } + if (userIcon != null) { + userAvatar = userIcon; + } else { + userAvatar = botIcon; + } + + quokka = new Quokka("data/tasks.txt"); + stage.show(); + + addBot("Hello! I’m " + AppInfo.PRODUCT_NAME + ". Type a command.", false); + } + + // -------- Chat row builders -------- + + private void addBot(String text, boolean isError) { + Label bubble = new Label(text); + bubble.setWrapText(true); + bubble.getStyleClass().add("bot-bubble"); + if (isError || text.startsWith("OOPS!!!")) { + bubble.getStyleClass().add("error-bubble"); + } + + bubble.maxWidthProperty().bind(lane.widthProperty().subtract(120)); + + HBox row = new HBox(10); + row.getStyleClass().add("row"); + row.setAlignment(Pos.TOP_LEFT); + + ImageView avatar = avatarView(botAvatar, 42, false); + + if (avatar != null) { + row.getChildren().addAll(avatar, bubble); + } else { + row.getChildren().add(bubble); + } + + dialog.getChildren().add(row); + } + + private void addUser(String text) { + Label chip = new Label(text); + chip.setWrapText(true); + chip.getStyleClass().add("user-chip"); + chip.maxWidthProperty().bind(lane.widthProperty().subtract(120)); + + HBox row = new HBox(10); + row.getStyleClass().add("row"); + row.setAlignment(Pos.TOP_RIGHT); + + ImageView avatar = avatarView(userAvatar, 42, true); + + if (avatar != null) { + row.getChildren().addAll(chip, avatar); + } else { + row.getChildren().add(chip); + } + + dialog.getChildren().add(row); + } + + // -------- Helpers -------- + + private Image loadImage(String classpath) { + try { + return new Image(Objects.requireNonNull( + getClass().getResourceAsStream(classpath), + "Missing " + classpath + )); + } catch (NullPointerException e) { + return null; + } + } + + private ImageView loadIconView(String classpath, double size) { + Image img = loadImage(classpath); + if (img == null) return null; + ImageView iv = new ImageView(img); + iv.setFitWidth(size); + iv.setFitHeight(size); + iv.setPreserveRatio(true); + return iv; + } + + private ImageView avatarView(Image img, double size, boolean mirror) { + if (img == null) return null; + ImageView iv = new ImageView(img); + iv.setFitWidth(size); + iv.setFitHeight(size); + iv.setPreserveRatio(true); + if (mirror) iv.setScaleX(-1); + iv.getStyleClass().add("avatar"); + return iv; + } + +} diff --git a/src/main/java/quokka/Parser.java b/src/main/java/quokka/Parser.java new file mode 100644 index 0000000000..cdec15c0c2 --- /dev/null +++ b/src/main/java/quokka/Parser.java @@ -0,0 +1,59 @@ +/** + * Lightweight parser that splits the command word and the remainder. + */ +package quokka; + +public class Parser { + + /** Normalize unicode spaces (e.g., NBSP) and trim. */ + private static String normalize(String s) { + if (s == null) return ""; + String t = s.replace('\u00A0', ' '); + t = t.trim(); + t = t.replaceAll("\\s+", " "); + return t; + } + + + public static String commandWord(String input) { + String s = normalize(input); + if (s.isEmpty()) return ""; + int sp = s.indexOf(' '); + String result = (sp == -1) ? s : s.substring(0, sp); + assert result != null : "Parser.commandWord must not return null"; + assert result.indexOf(' ') == -1 : "commandWord should contain no spaces"; + return result; + } + + public static String remainder(String input) { + String s = normalize(input); + if (s.isEmpty()) return ""; + int sp = s.indexOf(' '); + String result = (sp == -1) ? "" : s.substring(sp + 1).trim(); + assert result != null : "Parser.remainder must not return null"; + assert result.equals(result.trim()) : "remainder should be trimmed"; + return result; + } + + + /** Returns the number of occurrences of a substring token in text (non-overlapping). */ + public static int countToken(String text, String token) { + if (text == null || token == null || token.isEmpty()) return 0; + int count = 0, pos = 0; + while ((pos = text.indexOf(token, pos)) >= 0) { count++; pos += token.length(); } + return count; + } + + /** + * Split 'source' by the first occurrence of 'token', returning {left,right} trimmed. + * If token not found, returns {source.trim(), ""}. + */ + public static String[] splitOnce(String source, String token) { + String s = normalize(source); + int i = s.indexOf(token); + if (i < 0) return new String[]{ s, "" }; + String left = s.substring(0, i).trim(); + String right = s.substring(i + token.length()).trim(); + return new String[]{ left, right }; + } +} diff --git a/src/main/java/quokka/Quokka.java b/src/main/java/quokka/Quokka.java new file mode 100644 index 0000000000..b138b1f445 --- /dev/null +++ b/src/main/java/quokka/Quokka.java @@ -0,0 +1,244 @@ +package quokka; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import quokka.Reply; + + +/** + * Entry point and command dispatcher for the Quokka chatbot. + *

+ * Wires together Ui, TaskList, and Storage, and exposes a process(String) method + * that converts a raw user input into a formatted reply (with error/exit flags) + * without printing. Also provides a simple CLI run() loop for text mode. + */ +public class Quokka { + + private final Ui ui; + private final TaskList taskList; + private final Path dataFile; + + /** Creates a bot backed by data/tasks.txt. */ + public Quokka() { + this(Paths.get("data", "tasks.txt")); + } + + /** Creates a bot backed by the given data file (relative or absolute). */ + public Quokka(String filePath) { + this(Paths.get(filePath)); + } + + private Quokka(Path dataFile) { + this.ui = new Ui(); + this.taskList = new TaskList(); + this.dataFile = dataFile; + + // Load tasks, creating folders/files if missing. Skip malformed lines. + try { + Storage.load(this.dataFile, this.taskList.view()); + } catch (DukeException e) { + System.err.println("Warning: failed to load tasks: " + e.getMessage()); + } + } + + /** + * Main CLI loop (text UI). + * Prints the greeting, then processes lines until "bye". + */ + public void run() throws IOException { + printGreeting(); + while (true) { + String line = ui.readCommand(); + if (line == null) { // EOF behaves like bye + ui.showGoodbye(); + break; + } + Reply r = process(line); + if (r.error) { + ui.showError(r.message); + } else if (!r.message.isEmpty()) { + System.out.println(r.message); + } + if (r.exit) { + ui.showGoodbye(); + break; + } + } + } + + /** Processes one command and returns a reply (no printing). */ + public Reply process(String fullCommand) { + try { + String cmd = Parser.commandWord(fullCommand); + String rem = Parser.remainder(fullCommand); + + switch (cmd) { + case "": + return Reply.ok(""); // ignore pure whitespace + case "list": { + return Reply.ok(renderTaskList(taskList.view(), "Here are the tasks in your list:")); + } + case "todo": { + if (rem.isBlank()) { + return Reply.error("OOPS!!! The description of a todo cannot be empty."); + } + Task t = new Todo(rem); + if (taskList.containsDuplicate(t)) { + return Reply.error("Duplicate todo: an identical task already exists."); + } + taskList.add(t); + Storage.save(dataFile, taskList.view()); + return Reply.ok(formatAdded(t, taskList.size())); + } + case "deadline": { + if (rem.isBlank()) { + return Reply.error("OOPS!!! The description of a deadline cannot be empty."); + } + if (Parser.countToken(rem, " /by ") > 1) { + return Reply.error("OOPS!!! Duplicate '/by'. Use: deadline /by "); + } + int sep = rem.indexOf(" /by "); + if (sep < 0) { + return Reply.error("OOPS!!! Missing '/by' in deadline. Use: deadline /by "); + } + String desc = rem.substring(0, sep).trim(); + String byRaw = rem.substring(sep + 5).trim(); + if (desc.isEmpty() || byRaw.isEmpty()) { + return Reply.error("OOPS!!! Use: deadline /by "); + } + Task t; + try { + java.time.LocalDate by = quokka.util.Dates.parseStrictDate(byRaw); + t = new Deadline(desc, by.toString()); + } catch (IllegalArgumentException ex) { + return Reply.error("Invalid date for /by: " + ex.getMessage()); + } + if (taskList.containsDuplicate(t)) { + return Reply.error("Duplicate deadline: an identical task already exists."); + } + taskList.add(t); + Storage.save(dataFile, taskList.view()); + return Reply.ok(formatAdded(t, taskList.size())); + } + case "event": { + if (rem.isBlank()) { + return Reply.error("OOPS!!! The description of an event cannot be empty."); + } + if (Parser.countToken(rem, " /from ") != 1 || Parser.countToken(rem, " /to ") != 1) { + return Reply.error("OOPS!!! Use exactly one '/from' and one '/to': event /from /to "); + } + int f = rem.indexOf(" /from "); + int tIdx = rem.indexOf(" /to "); + if (f < 0 || tIdx < 0 || tIdx <= f) { + return Reply.error("OOPS!!! Use: event /from /to "); + } + String desc = rem.substring(0, f).trim(); + String fromRaw = rem.substring(f + 7, tIdx).trim(); + String toRaw = rem.substring(tIdx + 5).trim(); + if (desc.isEmpty() || fromRaw.isEmpty() || toRaw.isEmpty()) { + return Reply.error("OOPS!!! Use: event /from /to "); + } + + Task t; + try { + java.time.LocalDate from = quokka.util.Dates.parseStrictDate(fromRaw); + java.time.LocalDate to = quokka.util.Dates.parseStrictDate(toRaw); + if (!from.isBefore(to)) { + return Reply.error("Event start must be strictly before end."); + } + t = new Event(desc, from.toString(), to.toString()); + } catch (IllegalArgumentException ex) { + return Reply.error("Invalid date: " + ex.getMessage()); + } + + if (taskList.containsDuplicate(t)) { + return Reply.error("Duplicate event: an identical task already exists."); + } + taskList.add(t); + Storage.save(dataFile, taskList.view()); + return Reply.ok(formatAdded(t, taskList.size())); + } + case "mark": { + int idx0 = parseOneBasedIndex(rem); + Task t = taskList.get(idx0); + t.markAsDone(); + Storage.save(dataFile, taskList.view()); + return Reply.ok("Nice! I've marked this task as done:\n " + t); + } + case "unmark": { + int idx0 = parseOneBasedIndex(rem); + Task t = taskList.get(idx0); + t.markAsNotDone(); + Storage.save(dataFile, taskList.view()); + return Reply.ok("OK, I've marked this task as not done yet:\n " + t); + } + case "delete": { + int idx0 = parseOneBasedIndex(rem); + Task removed = taskList.removeAt(idx0); + Storage.save(dataFile, taskList.view()); + return Reply.ok("Noted. I've removed this task:\n " + removed + + "\nNow you have " + taskList.size() + " tasks in the list."); + } + case "find": { + if (rem.isBlank()) { + return Reply.error("OOPS!!! Provide a keyword to find."); + } + List matches = taskList.find(rem); + return Reply.ok(renderTaskList(matches, "Here are the matching tasks in your list:")); + } + case "bye": + return Reply.ok("Bye. Hope to see you again soon!").withExit(); + default: + return Reply.error(ui.showUnknownCommandError(cmd)); + } + } catch (DukeException e) { + return Reply.error(e.getMessage()); + } catch (IllegalArgumentException e) { // e.g., date parse issues from Deadline/Event + return Reply.error("OOPS!!! " + e.getMessage()); + } catch (Exception e) { + return Reply.error("OOPS!!! " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + + /* ===================== helpers ===================== */ + + private void printGreeting() { + String line = "____________________________________________________________"; + System.out.println(line); + System.out.println(" Hello! I'm Quokka"); + System.out.println(" What can I do for you?"); + System.out.println(line); + } + + private static String renderTaskList(List list, String header) { + if (list == null || list.isEmpty()) { + return "Your list is empty."; + } + StringBuilder sb = new StringBuilder(header).append('\n'); + for (int i = 0; i < list.size(); i++) { + sb.append(i + 1).append('.').append(list.get(i)).append('\n'); + } + return sb.toString().trim(); + } + + private static String formatAdded(Task t, int newSize) { + return "Got it. I've added this task:\n " + t + "\nNow you have " + newSize + " tasks in the list."; + } + + private int parseOneBasedIndex(String numStr) throws DukeException { + if (numStr == null || numStr.trim().isEmpty()) { + throw new DukeException("Please provide a task number."); + } + try { + int n = Integer.parseInt(numStr.trim()); + if (n < 1 || n > taskList.size()) { + throw new DukeException("Index out of range. Provide a number between 1 and " + taskList.size() + "."); + } + return n - 1; // convert to 0-based + } catch (NumberFormatException e) { + throw new DukeException("Please provide a valid task number."); + } + } +} diff --git a/src/main/java/quokka/Reply.java b/src/main/java/quokka/Reply.java new file mode 100644 index 0000000000..0ee4439c87 --- /dev/null +++ b/src/main/java/quokka/Reply.java @@ -0,0 +1,32 @@ +package quokka; + +/** + * Small DTO passed from backend to GUI. + * Encapsulates the reply text, an error flag for styling, and an exit flag for closing the app. + */ +public class Reply { + public final String message; + public final boolean error; + public final boolean exit; + + private Reply(String message, boolean error, boolean exit) { + this.message = message; + this.error = error; + this.exit = exit; + } + + /** Create a successful reply (not an error, not exit). */ + public static Reply ok(String message) { + return new Reply(message, false, false); + } + + /** Create an error reply (used for styling), but not exit. */ + public static Reply error(String message) { + return new Reply(message, true, false); + } + + /** Return a copy of this reply that also signals application exit. */ + public Reply withExit() { + return new Reply(this.message, this.error, true); + } +} diff --git a/src/main/java/quokka/Storage.java b/src/main/java/quokka/Storage.java new file mode 100644 index 0000000000..06dd94fa48 --- /dev/null +++ b/src/main/java/quokka/Storage.java @@ -0,0 +1,120 @@ +/** + * Persists tasks to a human-editable text file and loads them on startup. + * Tolerates legacy formats and corrupted lines where possible. + */ +package quokka; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.List; + +public final class Storage { + + /** Split regex that tolerates spaces around the '|' delimiter. */ + private static final String SPLIT = "\\s*\\|\\s*"; + + private Storage() {} + + /** Save all tasks to file atomically (write to temp, then move). */ + public static void save(Path file, List tasks) throws DukeException { + try { + Path parent = file.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + + Path tmp = Files.createTempFile(parent != null ? parent : file.getParent(), "quokka-", ".tmp"); + try (BufferedWriter bw = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) { + for (Task t : tasks) { + bw.write(t.toDataString()); + bw.newLine(); + } + } + // Try atomic move; if not supported, fall back to replace. + try { + Files.move(tmp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + throw new DukeException("Unable to save data: " + e.getMessage()); + } + } + + /** Load tasks from file; creates the file if absent. Corrupted lines are skipped with warnings. */ + public static void load(Path file, List out) throws DukeException { + try { + Path parent = file.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + if (!Files.exists(file)) { + Files.createFile(file); + return; + } + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + int lineNo = 0; + for (String raw : lines) { + lineNo++; + String line = stripBom(raw).trim(); + if (line.isEmpty()) continue; + try { + Task t = parseLine(line); + out.add(t); + } catch (Exception ex) { + System.err.println("Warning: skipped corrupted line " + lineNo + ": \"" + raw + "\" (" + ex.getMessage() + ")"); + } + } + } catch (AccessDeniedException e) { + throw new DukeException("Access denied to data file: " + file.toAbsolutePath()); + } catch (IOException e) { + throw new DukeException("Unable to load data: " + e.getMessage()); + } + } + + /** Parse one serialized line into a Task. */ + private static Task parseLine(String line) throws DukeException { + String[] parts = line.split(SPLIT); + if (parts.length < 3) throw new DukeException("Too few fields: " + line); + + String type = parts[0].trim(); + boolean done = parseDone(parts[1].trim()); + String desc = parts[2].trim(); + + Task t; + switch (type) { + case "T": + t = new Todo(desc); + break; + case "D": + if (parts.length < 4) throw new DukeException("Deadline missing date: " + line); + t = new Deadline(desc, parts[3].trim()); + break; + case "E": + if (parts.length < 5) throw new DukeException("Event missing dates: " + line); + t = new Event(desc, parts[3].trim(), parts[4].trim()); + break; + default: + throw new DukeException("Unknown type: " + type); + } + if (done) t.markAsDone(); + return t; + } + + /** Remove UTF-8 BOM if present. */ + private static String stripBom(String s) { + if (s != null && !s.isEmpty() && s.charAt(0) == '\uFEFF') { + return s.substring(1); + } + return s; + } + + /** Parses the done flag ("1" or "0"). */ + private static boolean parseDone(String s) throws DukeException { + if ("1".equals(s)) return true; + if ("0".equals(s)) return false; + throw new DukeException("Invalid done flag: " + s); + } +} diff --git a/src/main/java/quokka/Task.java b/src/main/java/quokka/Task.java new file mode 100644 index 0000000000..7068f55292 --- /dev/null +++ b/src/main/java/quokka/Task.java @@ -0,0 +1,47 @@ +/** + * Base type for all tasks. Holds common fields and serialization hooks. + */ + + +package quokka; + +public class Task { + protected String description; + protected boolean isDone; + protected final TaskType type; + + public Task(String description, TaskType type) { + this.description = description; + this.isDone = false; + this.type = type; + } + + public Task(String description, TaskType type, boolean isDone) { + this.description = description; + this.isDone = isDone; + this.type = type; + } + + public String getStatusIcon() { + return (isDone ? "X" : " "); + } + + public TaskType getType() { + return type; + } + + public void markAsDone() { isDone = true; } + public void markAsNotDone() { isDone = false; } + public String getDescription() { return description; } + public boolean isDone() { return isDone; } + + @Override + public String toString() { + return "[" + type.getLabel() + "][" + getStatusIcon() + "] " + description; + } + + public String toDataString() { + return type.getLabel() + " | " + (isDone ? "1" : "0") + " | " + description; + } + +} diff --git a/src/main/java/quokka/TaskList.java b/src/main/java/quokka/TaskList.java new file mode 100644 index 0000000000..fa23dc2cce --- /dev/null +++ b/src/main/java/quokka/TaskList.java @@ -0,0 +1,111 @@ +/** + * Mutable list of tasks. Provides operations to add/remove/get and to search (Level-9). + */ + + +package quokka; + +import java.util.ArrayList; +import java.util.List; + +public class TaskList { + private final List tasks; + + public TaskList() { this.tasks = new ArrayList<>(); } + public TaskList(List existing) { this.tasks = existing; } + /** Returns tasks whose description contains the keyword (case-insensitive). */ + public void add(Task... items) { + if (items == null) { + return; + } + for (Task t : items) { + if (t != null) { + tasks.add(t); + } + } + } + /** Returns tasks whose description contains the keyword (case-insensitive). */ + public Task removeAt(int idx0) { return tasks.remove(idx0); } + /** Returns tasks whose description contains the keyword (case-insensitive). */ + public Task get(int idx0) { return tasks.get(idx0); } + /** Returns tasks whose description contains the keyword (case-insensitive). */ + public int size() { return tasks.size(); } + /** Returns tasks whose description contains the keyword (case-insensitive). */ + public List view() { return tasks; } + + public List find(String keyword) { + String kw = keyword.toLowerCase(); + List out = new ArrayList<>(); + for (Task t : tasks) { + if (t.getDescription().toLowerCase().contains(kw)) { + out.add(t); + } + } + return out; + } + + /** Case-insensitive search by keyword (non-destructive). */ + public java.util.List findByKeyword(String keyword) { + if (keyword == null || keyword.isBlank()) { + return java.util.List.of(); + } + final String k = keyword.toLowerCase(); + return tasks.stream() + .filter(t -> t.getDescription().toLowerCase().contains(k)) + .collect(java.util.stream.Collectors.toList()); + } + + /** Count how many tasks are marked done. */ + public long countDone() { + return tasks.stream().filter(Task::isDone).count(); + } + + /** + * Returns true if a task with the same logical identity already exists. + * Identity is derived from type + normalized description (+ date fields + * for deadlines/events). Used to prevent adding near-duplicates. + */ + public boolean containsDuplicate(Task candidate) { + String keyB = dupKey(candidate); + for (Task t : tasks) { + if (dupKey(t).equals(keyB)) return true; + } + return false; + } + + /** + * Build a normalized key used for duplicate detection. + * For: + * - T: "T|desc" + * - D: "D|desc|by" + * - E: "E|desc|from|to" + * Description is lower-cased and trimmed; dates are taken from data string. + */ + private static String dupKey(Task t) { + String[] p = t.toDataString().split("\\s*\\|\\s*"); + if (p.length < 3) { + return t.toDataString().trim(); + } + String type = p[0].trim(); + String desc = p[2].trim().toLowerCase(); + + switch (type) { + case "T": + return "T|" + desc; + case "D": { + String by = (p.length >= 4) ? p[3].trim() : ""; + return "D|" + desc + "|" + by; + } + case "E": { + String from = (p.length >= 4) ? p[3].trim() : ""; + String to = (p.length >= 5) ? p[4].trim() : ""; + return "E|" + desc + "|" + from + "|" + to; + } + default: + StringBuilder sb = new StringBuilder(type).append('|').append(desc); + for (int i = 3; i < p.length; i++) sb.append('|').append(p[i].trim()); + return sb.toString(); + } + } + +} diff --git a/src/main/java/quokka/TaskType.java b/src/main/java/quokka/TaskType.java new file mode 100644 index 0000000000..03291576b7 --- /dev/null +++ b/src/main/java/quokka/TaskType.java @@ -0,0 +1,17 @@ +package quokka; + +public enum TaskType { + TODO("T"), + DEADLINE("D"), + EVENT("E"); + + private final String label; + + TaskType(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } +} diff --git a/src/main/java/quokka/Todo.java b/src/main/java/quokka/Todo.java new file mode 100644 index 0000000000..29fdad47ac --- /dev/null +++ b/src/main/java/quokka/Todo.java @@ -0,0 +1,18 @@ +/** A concrete task type. */ + +package quokka; + +public class Todo extends Task { + public Todo(String description) { + super(description, TaskType.TODO); + } + + public Todo(String description, boolean isDone) { + super(description, TaskType.TODO, isDone); + } + + @Override + public String toDataString() { + return "T | " + (isDone ? "1" : "0") + " | " + description; + } +} diff --git a/src/main/java/quokka/Ui.java b/src/main/java/quokka/Ui.java new file mode 100644 index 0000000000..b08127e345 --- /dev/null +++ b/src/main/java/quokka/Ui.java @@ -0,0 +1,113 @@ +package quokka; + +import java.util.List; +import java.util.Scanner; + +/** + * Handles user interaction concerns. + */ +public class Ui { + + private static final String DIVIDER = "____________________________________________________________"; + private final Scanner in = new Scanner(System.in); + + /* ===================== CLI helpers ===================== */ + + /** Reads one line from stdin. Returns null on EOF. */ + public String readCommand() { + try { + if (in.hasNextLine()) { + return in.nextLine(); + } + return null; // EOF + } catch (Exception e) { + return null; + } + } + + /** Prints a friendly goodbye (CLI). GUI uses the string-returning variant instead. */ + public void showGoodbye() { + System.out.println(DIVIDER); + System.out.println(" Bye. Hope to see you again soon!"); + System.out.println(DIVIDER); + } + + /** Prints an error block to the console (CLI). In GUI, pass the same message into a bot bubble. */ + public void showError(String message) { + System.out.println(DIVIDER); + System.out.println(" " + message); + System.out.println(DIVIDER); + } + + /* ===================== String-returning helpers (GUI or CLI formatting) ===================== */ + + public String getWelcomeMessage() { + return "Greetings, wanderer. I am the Chronicler of Quokka Hollow.\n" + + "What tale shall we inscribe today?"; + } + + public String byeMessage() { + return "Farewell, brave one. May the path ahead be lit, even in shadow."; + } + + /** Generic "unknown command" error string. */ + public String showUnknownCommandError(String cmd) { + return "Alas… I do not know this incantation: " + safe(cmd); + } + + /** Formats a full task list. */ + public String showTaskList(List list) { + if (list == null || list.isEmpty()) { + return "Your list is empty."; + } + StringBuilder sb = new StringBuilder("Here are the tasks in your list:\n"); + for (int i = 0; i < list.size(); i++) { + sb.append(i + 1).append('.').append(list.get(i)).append('\n'); + } + return rstrip(sb); + } + + /** Formats matching tasks (for 'find'). */ + public String showMatchingTasks(List list) { + if (list == null || list.isEmpty()) { + return "No matching tasks found."; + } + StringBuilder sb = new StringBuilder("Here are the matching tasks in your list:\n"); + for (int i = 0; i < list.size(); i++) { + sb.append(i + 1).append('.').append(list.get(i)).append('\n'); + } + return rstrip(sb); + } + + public String formatAdded(Task t, int newSize) { + return "A new entry etched into the Chronicle:\n " + t + + "\nYou now carry " + newSize + " burdens."; + } + + public String formatMarked(Task t) { + return "The deed is done, etched with certainty:\n " + t; + } + + public String formatUnmarked(Task t) { + return "The ink fades… the task remains unfinished:\n " + t; + } + + public String formatDeleted(Task t, int newSize) { + return "A memory erased from the Chronicle:\n " + t + + "\nOnly " + newSize + " tales remain."; + } + + + /* ===================== small utilities ===================== */ + private static String rstrip(StringBuilder sb) { + int len = sb.length(); + while (len > 0 && Character.isWhitespace(sb.charAt(len - 1))) { + len--; + } + return sb.substring(0, len); + } + + private static String safe(String s) { + return s == null ? "" : s; + } +} diff --git a/src/main/java/quokka/util/Dates.java b/src/main/java/quokka/util/Dates.java new file mode 100644 index 0000000000..57d557616d --- /dev/null +++ b/src/main/java/quokka/util/Dates.java @@ -0,0 +1,118 @@ +/** + * Parse a date using strict patterns (yyyy-MM-dd, d/M/yyyy, + * d MMM yyyy, etc.). Rejects invalid calendar dates. + * + * @param raw user input string + * @return parsed LocalDate + * @throws IllegalArgumentException if format is unrecognized/invalid + */ + + +package quokka.util; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** Date helpers: flexible parse + standard format. */ +public final class Dates { + private Dates() {} + + private static final DateTimeFormatter OUT_FMT = DateTimeFormatter.ofPattern("MMM d yyyy"); + + /** Parse many human inputs into a LocalDate (throws IllegalArgumentException if fails). */ + public static LocalDate parseFlexibleDate(String raw) { + if (raw == null) { + throw new IllegalArgumentException("date is null"); + } + String s = raw.trim(); + + try { return LocalDate.parse(s); } catch (DateTimeParseException ignored) {} + + s = s.replaceAll("(?i)(\\d{1,2})(st|nd|rd|th)", "$1").trim().replaceAll("\\s{2,}", " "); + + // strip trailing time-ish parts + s = s.replaceFirst("(?i)\\s+(\\d{3,4})$", ""); + s = s.replaceFirst("(?i)\\s+\\d{1,2}:\\d{2}([ap]m)?$", ""); + s = s.replaceFirst("(?i)\\s+\\d{1,2}\\s*-\\s*\\d{1,2}\\s*[ap]m$", ""); + + String[] patterns = { + "yyyy-MM-dd", + "d/M/uuuu", "d-M-uuuu", + "d MMM uuuu", "d MMMM uuuu", + "MMM d uuuu", "MMMM d uuuu" + }; + for (String p : patterns) { + try { return LocalDate.parse(s, DateTimeFormatter.ofPattern(p)); } + catch (DateTimeParseException ignored) {} + } + + String[] noYear = { "MMM d", "MMMM d", "d MMM", "d MMMM" }; + for (String p : noYear) { + try { + LocalDate base = LocalDate.parse(s, DateTimeFormatter.ofPattern(p)); + return base.withYear(LocalDate.now().getYear()); + } catch (DateTimeParseException ignored) {} + } + + throw new IllegalArgumentException("Unrecognized date format: \"" + raw + "\""); + } + + /** Format a date consistently for UI. */ + public static String fmt(LocalDate date) { + return date.format(OUT_FMT); + } + + /** + * Parse a date using strict patterns (yyyy-MM-dd, d/M/yyyy, d MMM yyyy, etc.). + * Rejects invalid calendar dates (e.g., 2025-02-30). + * + * @param raw user input string + * @return parsed LocalDate + * @throws IllegalArgumentException if format is unrecognized or invalid + */ + public static java.time.LocalDate parseStrictDate(String raw) { + if (raw == null) throw new IllegalArgumentException("date is null"); + String s = raw.trim(); + java.time.format.ResolverStyle STRICT = java.time.format.ResolverStyle.STRICT; + + String[] patterns = new String[]{ + "uuuu-MM-dd", "d/M/uuuu", "d-M-uuuu", "d.M.uuuu", + "uuuu/M/d", "uuuu-M-d", "M/d/uuuu", "d MMM uuuu", "MMM d uuuu" + }; + for (String p : patterns) { + try { + java.time.format.DateTimeFormatter f = + new java.time.format.DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendPattern(p) + .toFormatter() + .withResolverStyle(STRICT); + return java.time.LocalDate.parse(s, f); + } catch (java.time.format.DateTimeParseException ignored) {} + } + throw new IllegalArgumentException("Unparseable or invalid calendar date: " + raw); + } + + /** + * Validate a 24-hour HHmm time and return minutes since 00:00. + * + * @param raw four-digit time string in HHmm + * @return minutes in [0, 1439] + * @throws IllegalArgumentException if not HHmm or out of range + */ + public static int validateHHmm(String raw) { + if (raw == null) throw new IllegalArgumentException("time is null"); + String s = raw.trim(); + if (!s.matches("\\d{4}")) { + throw new IllegalArgumentException("Invalid time, expected HHmm: " + raw); + } + int hh = Integer.parseInt(s.substring(0, 2)); + int mm = Integer.parseInt(s.substring(2, 4)); + if (hh < 0 || hh > 23 || mm < 0 || mm > 59) { + throw new IllegalArgumentException("Invalid time, expected 0000..2359: " + raw); + } + return hh * 60 + mm; + } + +} diff --git a/src/main/resources/images/knight.png b/src/main/resources/images/knight.png new file mode 100644 index 0000000000..5643223a2b Binary files /dev/null and b/src/main/resources/images/knight.png differ diff --git a/src/main/resources/images/user.png b/src/main/resources/images/user.png new file mode 100644 index 0000000000..6adec06344 Binary files /dev/null and b/src/main/resources/images/user.png differ diff --git a/src/main/resources/quokka/view/hollowknight.css b/src/main/resources/quokka/view/hollowknight.css new file mode 100644 index 0000000000..608d2f0017 --- /dev/null +++ b/src/main/resources/quokka/view/hollowknight.css @@ -0,0 +1,99 @@ +/* === Base layout === */ +.hk-pane { + -fx-background-color: linear-gradient(to bottom, #0d0d1a, #0b0b16); +} + +.hk-headerbar { + -fx-alignment: center-left; + -fx-padding: 10 12; + -fx-background-color: #0f1220; + -fx-border-color: rgba(255,255,255,0.08); + -fx-border-width: 0 0 1 0; +} + +.hk-header { + -fx-text-fill: #f0f4ff; + -fx-font-size: 22px; + -fx-font-weight: bold; +} + +/* === Chat lane === */ +.lane { -fx-background-color: transparent; } + + +/* === Bubbles === */ +.bot-bubble, .user-chip { + -fx-padding: 10 14; + -fx-background-radius: 16; + -fx-font-size: 14px; + -fx-line-spacing: 2; +} + +.bot-bubble { + -fx-background-color: rgba(255,255,255,0.08); + -fx-text-fill: #e9eef7; +} + +.error-bubble { + -fx-background-color: rgba(220,50,47,0.25); + -fx-text-fill: #ffecec; + -fx-effect: dropshadow(three-pass-box, rgba(220,50,47,0.5), 12, 0.3, 0, 3); +} + +.user-chip { + -fx-background-color: linear-gradient(to bottom right, #4e7fff, #3a6be0); + -fx-text-fill: #ffffff; + -fx-font-weight: bold; +} + +/* === Avatars === */ +.avatar { + -fx-opacity: 0.9; + -fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.4), 6, 0.3, 0, 2); +} + +/* === Input === */ +.input-bar { + -fx-background-color: #0f1220; + -fx-border-color: rgba(255,255,255,0.08); + -fx-border-width: 1 0 0 0; + -fx-padding: 8 12; +} + +.hk-input { + -fx-background-radius: 12; + -fx-background-color: rgba(255,255,255,0.08); + -fx-text-fill: #f0f4ff; + -fx-prompt-text-fill: rgba(240,244,255,0.4); + -fx-padding: 8 12; + -fx-border-color: rgba(255,255,255,0.12); + -fx-border-radius: 12; +} + +.hk-input:focused { + -fx-border-color: #7aa7ff; + -fx-background-color: rgba(255,255,255,0.12); +} + +.hk-send { + -fx-background-radius: 12; + -fx-background-color: linear-gradient(to bottom right, #4e7fff, #3a6be0); + -fx-text-fill: white; + -fx-font-weight: bold; + -fx-padding: 8 14; +} +.hk-send:hover { + -fx-background-color: linear-gradient(to bottom right, #5b88ff, #4774e6); +} + +.scroll-pane, +.scroll-pane .viewport, +.scroll-pane .content { + -fx-background-color: transparent; + -fx-background: transparent; +} + +* { + -fx-focus-color: transparent; + -fx-faint-focus-color: transparent; +} diff --git a/src/test/java/quokka/DatesTest.java b/src/test/java/quokka/DatesTest.java new file mode 100644 index 0000000000..87b01b6167 --- /dev/null +++ b/src/test/java/quokka/DatesTest.java @@ -0,0 +1,48 @@ +package quokka; + +import org.junit.jupiter.api.Test; +import quokka.util.Dates; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +class DatesTest { + + @Test + void parseStrictDate_acceptsCommonPatterns() { + assertEquals(LocalDate.of(2025, 9, 10), Dates.parseStrictDate("2025-09-10")); + assertEquals(LocalDate.of(2025, 9, 10), Dates.parseStrictDate("10/9/2025")); + assertEquals(LocalDate.of(2025, 9, 10), Dates.parseStrictDate("10 Sep 2025")); + assertEquals(LocalDate.of(2025, 9, 1), Dates.parseStrictDate("1-9-2025")); + } + + @Test + void parseStrictDate_rejectsImpossibleDates() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> Dates.parseStrictDate("2025-02-30")); + assertTrue(ex.getMessage().toLowerCase().contains("date") || ex.getMessage().toLowerCase().contains("parse")); + } + + @Test + void validateHHmm_happyPath() { + assertEquals(0, Dates.validateHHmm("0000")); + assertEquals(9 * 60 + 30, Dates.validateHHmm("0930")); + assertEquals(23 * 60 + 59, Dates.validateHHmm("2359")); + } + + @Test + void validateHHmm_rejectsBadTimes() { + assertThrows(IllegalArgumentException.class, () -> Dates.validateHHmm("24:00")); + assertThrows(IllegalArgumentException.class, () -> Dates.validateHHmm("2460")); + assertThrows(IllegalArgumentException.class, () -> Dates.validateHHmm("abcd")); + assertThrows(IllegalArgumentException.class, () -> Dates.validateHHmm("9999")); + } + + @Test + void fmt_formatsShortMonthStyle() { + assertEquals("Sep 10 2025", + Dates.fmt(java.time.LocalDate.of(2025, 9, 10))); + } + +} diff --git a/src/test/java/quokka/DeadlineTest.java b/src/test/java/quokka/DeadlineTest.java new file mode 100644 index 0000000000..ac50f4efb2 --- /dev/null +++ b/src/test/java/quokka/DeadlineTest.java @@ -0,0 +1,20 @@ +package quokka; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class DeadlineTest { + @Test + void isoParseAndFormat() { + Deadline d = new Deadline("return book", "2019-10-15"); + assertEquals("D | 0 | return book | 2019-10-15", d.toDataString()); + assertTrue(d.toString().contains("2019"), d.toString()); + } + + @Test + void flexibleParsing_dMY_withTimeIgnored() { + Deadline d = new Deadline("return book", "2/12/2019 1800"); + assertEquals("D | 0 | return book | 2019-12-02", d.toDataString()); + assertTrue(d.toString().contains("2019"), d.toString()); + } +} diff --git a/src/test/java/quokka/EventValidationTest.java b/src/test/java/quokka/EventValidationTest.java new file mode 100644 index 0000000000..9f4da6985a --- /dev/null +++ b/src/test/java/quokka/EventValidationTest.java @@ -0,0 +1,30 @@ +package quokka; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class EventValidationTest { + + @Test + void constructor_rejectsSameOrReversedRange() { + IllegalArgumentException same = assertThrows(IllegalArgumentException.class, + () -> new Event("demo", "2025-09-10", "2025-09-10")); + assertTrue(same.getMessage().toLowerCase().contains("start")); + + IllegalArgumentException reversed = assertThrows(IllegalArgumentException.class, + () -> new Event("demo", "2025-09-11", "2025-09-10")); + assertTrue(reversed.getMessage().toLowerCase().contains("start")); + } + + @Test + void constructor_acceptsProperRange() { + Event e = new Event("demo", "2025-09-10", "2025-09-12"); + assertNotNull(e); + } + + @Test + void constructor_rejectsImpossibleDate() { + assertThrows(IllegalArgumentException.class, + () -> new Event("demo", "2025-02-30", "2025-03-01")); + } +} diff --git a/src/test/java/quokka/ParserTest.java b/src/test/java/quokka/ParserTest.java new file mode 100644 index 0000000000..3e710c2745 --- /dev/null +++ b/src/test/java/quokka/ParserTest.java @@ -0,0 +1,35 @@ +package quokka; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ParserTest { + + @Test + void commandWord_trimsAndNormalizes() { + String input = "\u00A0 todo\u00A0read book "; + assertEquals("todo", Parser.commandWord(input)); + } + + @Test + void remainder_trimsAndNormalizes() { + String input = "\u00A0deadline\u00A0 read book \u00A0 /by 2025-09-10 "; + assertEquals("read book /by 2025-09-10", Parser.remainder(input)); + } + + @Test + void countToken_countsNonOverlapping() { + assertEquals(0, Parser.countToken("", " /by ")); + assertEquals(1, Parser.countToken("a /by b", " /by ")); + assertEquals(2, Parser.countToken("x /from a /to b", " /")); + } + + @Test + void splitOnce_splitsOnFirstOccurrence() { + String[] p = Parser.splitOnce("desc /by 2024-01-01", " /by "); + assertArrayEquals(new String[]{"desc", "2024-01-01"}, p); + + String[] p2 = Parser.splitOnce("desc only", " /by "); + assertArrayEquals(new String[]{"desc only", ""}, p2); + } +} diff --git a/src/test/java/quokka/StorageRoundTripTest.java b/src/test/java/quokka/StorageRoundTripTest.java new file mode 100644 index 0000000000..ad02924d2b --- /dev/null +++ b/src/test/java/quokka/StorageRoundTripTest.java @@ -0,0 +1,40 @@ +package quokka; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public class StorageRoundTripTest { + @Test + void saveThenLoad_roundTripPreservesData() throws Exception { + Path tempDir = Files.createTempDirectory("quokka-test-"); + Path file = tempDir.resolve("duke.txt"); + + List out = new ArrayList<>(); + Todo t1 = new Todo("read book"); + Deadline t2 = new Deadline("return book", "2019-10-15"); + Event t3 = new Event("project", "2019-12-01", "2019-12-02"); + t2.markAsDone(); + + out.add(t1); out.add(t2); out.add(t3); + Storage.save(file, out); + + List in = new ArrayList<>(); + Storage.load(file, in); + + assertEquals(3, in.size()); + assertInstanceOf(Todo.class, in.get(0)); + assertEquals("read book", in.get(0).getDescription()); + assertFalse(in.get(0).isDone()); + assertInstanceOf(Deadline.class, in.get(1)); + assertEquals("return book", in.get(1).getDescription()); + assertTrue(in.get(1).isDone()); + assertInstanceOf(Event.class, in.get(2)); + assertEquals("project", in.get(2).getDescription()); + assertFalse(in.get(2).isDone()); + } +} diff --git a/src/test/java/quokka/StorageTest.java b/src/test/java/quokka/StorageTest.java new file mode 100644 index 0000000000..0519a297e4 --- /dev/null +++ b/src/test/java/quokka/StorageTest.java @@ -0,0 +1,55 @@ +package quokka; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class StorageTest { + + @TempDir + Path tmpDir; + + @Test + void saveAndLoad_roundTripAndSkipCorrupted() throws Exception { + Path data = tmpDir.resolve("quokka-data.txt"); + + List tasks = new ArrayList<>(); + tasks.add(new Todo("read book")); + tasks.add(new Deadline("submit report", "2025-09-10")); + tasks.add(new Event("camp", "2025-09-10", "2025-09-12")); + + Storage.save(data, tasks); + + Files.writeString(data, "X | 0 | nonsense line\n", StandardCharsets.UTF_8, StandardOpenOption.APPEND); + + List loaded = new ArrayList<>(); + Storage.load(data, loaded); + + assertEquals(3, loaded.size()); + + assertTrue(loaded.get(0) instanceof Todo); + assertTrue(loaded.get(1) instanceof Deadline); + assertTrue(loaded.get(2) instanceof Event); + } + + @Test + void save_createsParentDirectories() throws Exception { + Path nested = tmpDir.resolve("a/b/c/data.txt"); + List tasks = new ArrayList<>(); + tasks.add(new Todo("hello")); + + Storage.save(nested, tasks); + assertTrue(Files.exists(nested), "Data file should be created along nested dirs"); + + List loaded = new ArrayList<>(); + Storage.load(nested, loaded); + assertEquals(1, loaded.size()); + assertTrue(loaded.get(0) instanceof Todo); + } +} diff --git a/src/test/java/quokka/TaskListDuplicateTest.java b/src/test/java/quokka/TaskListDuplicateTest.java new file mode 100644 index 0000000000..8f79710add --- /dev/null +++ b/src/test/java/quokka/TaskListDuplicateTest.java @@ -0,0 +1,45 @@ +package quokka; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +class TaskListDuplicateTest { + + @Test + void containsDuplicate_todo_caseInsensitiveOnDesc() { + TaskList tl = new TaskList(new ArrayList<>()); + Todo a = new Todo("Read Book"); + Todo b = new Todo("read book"); + + assertFalse(tl.containsDuplicate(a)); + tl.add(a); + assertTrue(tl.containsDuplicate(b)); + } + + @Test + void containsDuplicate_deadline_usesDateField() { + TaskList tl = new TaskList(new ArrayList<>()); + Deadline a = new Deadline("submit report", "2025-09-10"); + Deadline b = new Deadline("Submit Report", "2025-09-10"); + Deadline c = new Deadline("Submit Report", "2025-09-11"); + + tl.add(a); + assertTrue(tl.containsDuplicate(b)); + assertFalse(tl.containsDuplicate(c)); + } + + @Test + void containsDuplicate_event_usesBothDates() { + TaskList tl = new TaskList(new ArrayList<>()); + Event a = new Event("trip", "2025-09-10", "2025-09-12"); + Event b = new Event("TRIP", "2025-09-10", "2025-09-12"); + Event c = new Event("trip", "2025-09-11", "2025-09-12"); + + tl.add(a); + assertTrue(tl.containsDuplicate(b)); + assertFalse(tl.containsDuplicate(c)); + } +} diff --git a/text-ui-test/ACTUAL.TXT b/text-ui-test/ACTUAL.TXT new file mode 100644 index 0000000000..e70a09adc1 --- /dev/null +++ b/text-ui-test/ACTUAL.TXT @@ -0,0 +1,36 @@ +____________________________________________________________ + Hello! I'm quokka.Quokka + What can I do for you? +____________________________________________________________ +____________________________________________________________ + Got it. I've added this task: + [T][ ] read book + Now you have 1 task in the list. +____________________________________________________________ +____________________________________________________________ + Got it. I've added this task: + [D][ ] return book (by: Sunday) + Now you have 2 tasks in the list. +____________________________________________________________ +____________________________________________________________ + Got it. I've added this task: + [E][ ] project meeting (from: Mon 2pm to: 4pm) + Now you have 3 tasks in the list. +____________________________________________________________ +____________________________________________________________ + Here are the tasks in your list: + 1.[T][ ] read book + 2.[D][ ] return book (by: Sunday) + 3.[E][ ] project meeting (from: Mon 2pm to: 4pm) +____________________________________________________________ +____________________________________________________________ + Nice! I've marked this task as done: + [D][X] return book (by: Sunday) +____________________________________________________________ +____________________________________________________________ + OK, I've marked this task as not done yet: + [D][ ] return book (by: Sunday) +____________________________________________________________ +____________________________________________________________ + Bye. Hope to see you again soon! +____________________________________________________________ diff --git a/text-ui-test/EXPECTED.TXT b/text-ui-test/EXPECTED.TXT index 657e74f6e7..e70a09adc1 100644 --- a/text-ui-test/EXPECTED.TXT +++ b/text-ui-test/EXPECTED.TXT @@ -1,7 +1,36 @@ -Hello from - ____ _ -| _ \ _ _| | _____ -| | | | | | | |/ / _ \ -| |_| | |_| | < __/ -|____/ \__,_|_|\_\___| - +____________________________________________________________ + Hello! I'm quokka.Quokka + What can I do for you? +____________________________________________________________ +____________________________________________________________ + Got it. I've added this task: + [T][ ] read book + Now you have 1 task in the list. +____________________________________________________________ +____________________________________________________________ + Got it. I've added this task: + [D][ ] return book (by: Sunday) + Now you have 2 tasks in the list. +____________________________________________________________ +____________________________________________________________ + Got it. I've added this task: + [E][ ] project meeting (from: Mon 2pm to: 4pm) + Now you have 3 tasks in the list. +____________________________________________________________ +____________________________________________________________ + Here are the tasks in your list: + 1.[T][ ] read book + 2.[D][ ] return book (by: Sunday) + 3.[E][ ] project meeting (from: Mon 2pm to: 4pm) +____________________________________________________________ +____________________________________________________________ + Nice! I've marked this task as done: + [D][X] return book (by: Sunday) +____________________________________________________________ +____________________________________________________________ + OK, I've marked this task as not done yet: + [D][ ] return book (by: Sunday) +____________________________________________________________ +____________________________________________________________ + Bye. Hope to see you again soon! +____________________________________________________________ diff --git a/text-ui-test/input.txt b/text-ui-test/input.txt index e69de29bb2..3a1c77435e 100644 --- a/text-ui-test/input.txt +++ b/text-ui-test/input.txt @@ -0,0 +1,7 @@ +todo read book +deadline return book /by Sunday +event project meeting /from Mon 2pm /to 4pm +list +mark 2 +unmark 2 +bye diff --git a/text-ui-test/runtest.bat b/text-ui-test/runtest.bat index 0873744649..2aa9a9d7f8 100644 --- a/text-ui-test/runtest.bat +++ b/text-ui-test/runtest.bat @@ -1,21 +1,23 @@ -@ECHO OFF +@echo off +setlocal -REM create bin directory if it doesn't exist -if not exist ..\bin mkdir ..\bin +rem Clean previous ACTUAL.TXT if exists +del text-ui-test\ACTUAL.TXT 2> NUL -REM delete output from previous run -if exist ACTUAL.TXT del ACTUAL.TXT +rem Compile into out/ +if not exist out mkdir out +javac -cp src\main\java -d out src\main\java\*.java -REM compile the code into the bin folder -javac -cp ..\src\main\java -Xlint:none -d ..\bin ..\src\main\java\*.java -IF ERRORLEVEL 1 ( - echo ********** BUILD FAILURE ********** - exit /b 1 -) -REM no error here, errorlevel == 0 +rem Run with redirected I/O +java -classpath out quokka.Quokka < text-ui-test\input.txt > text-ui-test\ACTUAL.TXT -REM run the program, feed commands from input.txt file and redirect the output to the ACTUAL.TXT -java -classpath ..\bin Duke < input.txt > ACTUAL.TXT +rem Compare using fc; suppress diff output +fc text-ui-test\EXPECTED.TXT text-ui-test\ACTUAL.TXT > NUL +if %errorlevel%==0 ( + echo All tests passed +) else ( + echo Tests failed + exit /b 1 +) -REM compare the output to the expected output -FC ACTUAL.TXT EXPECTED.TXT +endlocal diff --git a/text-ui-test/runtest.sh b/text-ui-test/runtest.sh old mode 100644 new mode 100755