diff --git a/docs/README.md b/docs/README.md index 617dd9650b..d61ee49495 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,26 @@ Example: `keyword (optional arguments)` expected output ``` +## Archiving tasks + +Move every task out of your active list into a separate archive file, +keeping a record of them without cluttering your day-to-day list. +Archiving is cumulative: doing it again later adds to the same archive +file instead of overwriting it. + +Example: `archive` + +``` +Archived 2 task(s). Your list is now empty. +``` + +If your list is already empty, Echo tells you there's nothing to +archive instead: + +``` +There's nothing to archive - your list is already empty. +``` + ## Feature ABC // Feature details diff --git a/src/main/java/echo/Echo.java b/src/main/java/echo/Echo.java index afd87c9d9c..008ae92bb2 100644 --- a/src/main/java/echo/Echo.java +++ b/src/main/java/echo/Echo.java @@ -154,6 +154,13 @@ private void executeCommand(String cmd, String rest) throws EchoException { ui.showTasksOnDate(date, matches); break; } + case "archive": { + List archived = tasks.clearAll(); + ui.showArchived(archived.size()); + storage.archive(archived); + storage.save(tasks.getAll()); + break; + } case "find": { String keyword = Parser.parseFindKeyword(rest); List matches = new ArrayList<>(); diff --git a/src/main/java/echo/storage/Storage.java b/src/main/java/echo/storage/Storage.java index bed396499e..efb93de979 100644 --- a/src/main/java/echo/storage/Storage.java +++ b/src/main/java/echo/storage/Storage.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -14,22 +15,25 @@ import echo.task.Todo; /** - * Reads and writes the task list to a save file on disk. The file location - * is fixed at construction time; every other method operates on that one - * path. Load/save never throw: any IO problem is reported with a short - * warning so a save-file issue never prevents the chatbot from starting or - * running. + * Reads and writes the task list to a save file on disk, and appends + * archived tasks to a separate archive file. Both file locations are + * fixed at construction time. Load/save/archive never throw: any IO + * problem is reported with a short warning so a save-file issue never + * prevents the chatbot from starting or running. */ public class Storage { private final Path filePath; + private final Path archiveFilePath; /** - * Creates a Storage bound to the given save-file path. + * Creates a Storage bound to the given save-file path. Archived tasks + * go to a sibling file named "archive.txt" in the same folder. * * @param filePath Relative path to the save file, e.g. "data/echo.txt". */ public Storage(String filePath) { this.filePath = Path.of(filePath); + this.archiveFilePath = this.filePath.resolveSibling("archive.txt"); } /** @@ -40,7 +44,7 @@ public Storage(String filePath) { public List load() { List tasks = new ArrayList<>(); try { - ensureFileExists(); + ensureFileExists(filePath); for (String line : Files.readAllLines(filePath)) { if (line.isBlank()) { continue; @@ -62,7 +66,7 @@ public List load() { */ public void save(List tasks) { try { - ensureFileExists(); + ensureFileExists(filePath); List lines = new ArrayList<>(); for (Task task : tasks) { lines.add(task.toFileFormat()); @@ -74,16 +78,39 @@ public void save(List tasks) { } /** - * Creates the save file's parent folder and the file itself if either - * is missing, so load()/save() never have to handle a missing path. + * Appends the given tasks to the archive file, creating it (and its + * parent folder) if missing. Previously archived tasks are kept, so + * archiving is cumulative across multiple uses rather than + * overwriting what came before. + * + * @param tasks The tasks to archive. + */ + public void archive(List tasks) { + try { + ensureFileExists(archiveFilePath); + List lines = new ArrayList<>(); + for (Task task : tasks) { + lines.add(task.toFileFormat()); + } + Files.write(archiveFilePath, lines, StandardOpenOption.APPEND); + } catch (IOException e) { + System.out.println("Warning: could not archive tasks (" + e.getMessage() + ")."); + } + } + + /** + * Creates the given file's parent folder and the file itself if either + * is missing, so load()/save()/archive() never have to handle a + * missing path. Shared by both the save file and the archive file, + * which otherwise need this exact same setup. */ - private void ensureFileExists() throws IOException { - Path parent = filePath.getParent(); + private void ensureFileExists(Path path) throws IOException { + Path parent = path.getParent(); if (parent != null && Files.notExists(parent)) { Files.createDirectories(parent); } - if (Files.notExists(filePath)) { - Files.createFile(filePath); + if (Files.notExists(path)) { + Files.createFile(path); } } diff --git a/src/main/java/echo/task/TaskList.java b/src/main/java/echo/task/TaskList.java index ed9d93dd47..6288d1120b 100644 --- a/src/main/java/echo/task/TaskList.java +++ b/src/main/java/echo/task/TaskList.java @@ -1,5 +1,6 @@ package echo.task; +import java.util.ArrayList; import java.util.List; /** @@ -50,6 +51,17 @@ public int size() { return tasks.size(); } + /** + * Removes every task from the list and returns them, in their + * previous order, so a caller (e.g. an archive command) can do + * something with them before the list becomes empty. + */ + public List clearAll() { + List removed = new ArrayList<>(tasks); + tasks.clear(); + return removed; + } + /** * Returns the underlying list, for callers (Ui rendering, Storage * saving) that need to read or persist every task. diff --git a/src/main/java/echo/ui/Ui.java b/src/main/java/echo/ui/Ui.java index 66b228b1e3..863ea6fe90 100644 --- a/src/main/java/echo/ui/Ui.java +++ b/src/main/java/echo/ui/Ui.java @@ -150,6 +150,21 @@ public void showTasksOnDate(LocalDate date, List matches) { } } + /** + * Prints confirmation that the given number of tasks were archived + * and the active list is now empty, or a "nothing to archive" + * message if the count is zero. + * + * @param count How many tasks were archived. + */ + public void showArchived(int count) { + if (count == 0) { + System.out.println("There's nothing to archive - your list is already empty."); + return; + } + System.out.println("Archived " + count + " task(s). Your list is now empty."); + } + /** * Prints every task in the given list as a keyword match, numbered * from 1, or a "(none)" fallback if the list is empty. diff --git a/src/test/java/echo/storage/StorageTest.java b/src/test/java/echo/storage/StorageTest.java new file mode 100644 index 0000000000..d2633adc6f --- /dev/null +++ b/src/test/java/echo/storage/StorageTest.java @@ -0,0 +1,43 @@ +package echo.storage; // same package as the class being tested + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import echo.task.Task; +import echo.task.Todo; + +public class StorageTest { + @TempDir + Path tempDir; + + @Test + public void archive_nonEmptyList_appendsToArchiveFile() throws IOException { + Storage storage = new Storage(tempDir.resolve("echo.txt").toString()); + List tasks = List.of(new Todo("read book"), new Todo("write essay")); + + storage.archive(tasks); + + Path archiveFile = tempDir.resolve("archive.txt"); + List lines = Files.readAllLines(archiveFile); + assertEquals(List.of("T | 0 | read book", "T | 0 | write essay"), lines); + } + + @Test + public void archive_calledTwice_accumulatesAcrossCalls() throws IOException { + Storage storage = new Storage(tempDir.resolve("echo.txt").toString()); + + storage.archive(List.of(new Todo("first task"))); + storage.archive(List.of(new Todo("second task"))); + + Path archiveFile = tempDir.resolve("archive.txt"); + List lines = Files.readAllLines(archiveFile); + assertEquals(List.of("T | 0 | first task", "T | 0 | second task"), lines); + } +} diff --git a/src/test/java/echo/task/TaskListTest.java b/src/test/java/echo/task/TaskListTest.java new file mode 100644 index 0000000000..b30bce04dd --- /dev/null +++ b/src/test/java/echo/task/TaskListTest.java @@ -0,0 +1,36 @@ +package echo.task; // same package as the class being tested + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +public class TaskListTest { + @Test + public void clearAll_nonEmptyList_returnsRemovedTasksAndEmptiesList() { + List initial = new ArrayList<>(); + initial.add(new Todo("read book")); + initial.add(new Todo("write essay")); + TaskList taskList = new TaskList(initial); + + List removed = taskList.clearAll(); + + assertEquals(2, removed.size()); + assertEquals("[T][ ] read book", removed.get(0).toString()); + assertEquals("[T][ ] write essay", removed.get(1).toString()); + assertEquals(0, taskList.size()); + } + + @Test + public void clearAll_emptyList_returnsEmptyListAndStaysEmpty() { + TaskList taskList = new TaskList(new ArrayList<>()); + + List removed = taskList.clearAll(); + + assertTrue(removed.isEmpty()); + assertEquals(0, taskList.size()); + } +} diff --git a/tests/test-plan.md b/tests/test-plan.md new file mode 100644 index 0000000000..04553c1e2d --- /dev/null +++ b/tests/test-plan.md @@ -0,0 +1,22 @@ +# Manual test plan + +This file did not exist before the `archive` command was added, so it +currently only covers that feature. It is not (yet) a full manual test +plan for the whole app. + +## `archive` + +Archives every task currently in the list: appends each one to +`data/archive.txt` (relative to wherever Echo is run from) and clears +the active list. Archiving is cumulative — running `archive` again +later appends to the same archive file rather than overwriting it. + +| # | Steps | Expected result | +|---|-------|------------------| +| 1 | Add a few tasks (`todo`, `deadline`, `event`), then run `archive`. | Echo replies `Archived task(s). Your list is now empty.` where `` matches the number of tasks added. | +| 2 | Run `list` right after test 1. | Echo replies `Here are the tasks in your list:` with no tasks listed. | +| 3 | Inspect `data/echo.txt`. | The file is empty (or contains no task lines). | +| 4 | Inspect `data/archive.txt`. | Contains one save-file-format line per task archived in test 1, in the order they were added. | +| 5 | With an empty task list, run `archive`. | Echo replies `There's nothing to archive - your list is already empty.` `data/archive.txt` is unchanged. | +| 6 | Add a new task, run `archive`, then inspect `data/archive.txt` again. | The file now contains the lines from test 4 **plus** the new task's line appended after them — nothing from the earlier archive run is lost or overwritten. | +| 7 | Restart Echo (reload from `data/echo.txt`) after archiving. | The reloaded list is empty; archived tasks do not reappear (they only exist in `data/archive.txt`). |