File I/O & NIO.2

Project: A File-Based Notes Tool

15 min Lesson 10 of 13

Project: A File-Based Notes Tool

Every concept taught across this tutorial — Path, Files, buffered streams, try-with-resources, and character encoding — converges in this capstone project. You will build a small but complete command-line notes tool that persists notes to a plain-text file, supports listing, appending, deleting by index, and searching. The goal is not just to make it work, but to make deliberate design choices and understand the trade-offs behind each one.

What the tool does

  • add — append a new note to the file.
  • list — print all notes with their 1-based index.
  • delete <index> — remove the note at the given index.
  • search <term> — print every note containing the search term (case-insensitive).

Notes are stored one per line in ~/.notes/notes.txt. That single file is the entire "database".

Project skeleton

Keep things in one file for brevity; in a real project you would split NoteRepository, NoteService, and Main into separate classes.

import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.List; import java.util.stream.Collectors; public class NotesTool { private static final Path NOTES_DIR = Path.of(System.getProperty("user.home"), ".notes"); private static final Path NOTES_FILE = NOTES_DIR.resolve("notes.txt"); public static void main(String[] args) throws IOException { ensureFileExists(); if (args.length == 0) { printUsage(); return; } switch (args[0]) { case "add" -> add(joinArgs(args, 1)); case "list" -> list(); case "delete" -> delete(Integer.parseInt(args[1])); case "search" -> search(joinArgs(args, 1)); default -> printUsage(); } } private static String joinArgs(String[] args, int from) { StringBuilder sb = new StringBuilder(); for (int i = from; i < args.length; i++) { if (i > from) sb.append(' '); sb.append(args[i]); } return sb.toString(); } private static void printUsage() { System.out.println("Usage: notes <add|list|delete|search> [args]"); } }
Why user.home? Storing data in the user's home directory (~/.notes/) is a UNIX/Linux/macOS convention that keeps personal data separate from the application itself. It also means the tool works without elevated permissions and survives application reinstalls.

Bootstrapping the storage directory

Files.createDirectories is idempotent — it creates every missing segment in the path and silently succeeds if the path already exists. Always prefer it over Files.createDirectory when you cannot guarantee the parent exists.

private static void ensureFileExists() throws IOException { Files.createDirectories(NOTES_DIR); // no-op if already there if (!Files.exists(NOTES_FILE)) { Files.createFile(NOTES_FILE); // only when truly absent } }

Adding a note

Use Files.writeString with StandardOpenOption.APPEND to add a line without reading the whole file. This is O(1) in terms of memory regardless of how many notes already exist.

private static void add(String note) throws IOException { if (note.isBlank()) { System.out.println("Note cannot be empty."); return; } // Sanitise: strip embedded newlines so one call == exactly one note line String sanitised = note.replace('\n', ' ').replace('\r', ' ').strip(); Files.writeString(NOTES_FILE, sanitised + System.lineSeparator(), StandardCharsets.UTF_8, StandardOpenOption.APPEND); System.out.println("Note added."); }
Always specify the charset. Files.writeString accepts a Charset overload. Defaulting to the platform charset is a portability trap — a file written on Windows (Cp1252) may be unreadable on a Linux server (UTF-8). Hardcode StandardCharsets.UTF_8 everywhere.

Listing notes

Files.readAllLines loads every line into a List<String>. For a notes file this is fine; if the file could be gigabytes, switch to Files.lines() (a lazy stream) instead.

private static void list() throws IOException { List<String> notes = Files.readAllLines(NOTES_FILE, StandardCharsets.UTF_8); if (notes.isEmpty()) { System.out.println("No notes yet."); return; } for (int i = 0; i < notes.size(); i++) { System.out.printf("[%d] %s%n", i + 1, notes.get(i)); } }

Deleting a note by index

There is no efficient way to delete a line from the middle of a plain-text file in place — file systems do not support shrinking a region without rewriting everything after it. The standard pattern is read all, remove, write all. For a notes tool this is acceptable; for a multi-gigabyte log you would use a different storage format.

private static void delete(int index) throws IOException { List<String> notes = Files.readAllLines(NOTES_FILE, StandardCharsets.UTF_8); if (index < 1 || index > notes.size()) { System.out.println("Invalid index. Use 'list' to see valid indices."); return; } String removed = notes.remove(index - 1); // convert 1-based to 0-based Files.write(NOTES_FILE, notes, // writes each element as a line StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); System.out.println("Deleted: " + removed); }
Beware of concurrent access. The read-modify-write cycle above is not atomic. If two processes run the tool simultaneously, one write will silently overwrite the other. For a personal notes tool this is irrelevant, but in a shared environment you would need file locking via FileChannel.lock() or a proper database.

Searching notes

Use the streams API together with Files.lines() to avoid loading lines that do not match into memory. The result is printed with indices relative to the original file so the user can delete them by number.

private static void search(String term) throws IOException { String lower = term.toLowerCase(); List<String> all = Files.readAllLines(NOTES_FILE, StandardCharsets.UTF_8); boolean found = false; for (int i = 0; i < all.size(); i++) { if (all.get(i).toLowerCase().contains(lower)) { System.out.printf("[%d] %s%n", i + 1, all.get(i)); found = true; } } if (!found) { System.out.println("No notes match \"" + term + "\"."); } }

Putting it all together — running the tool

Compile and run from the terminal:

javac NotesTool.java java NotesTool add "Buy groceries" java NotesTool add "Read chapter 9 of Effective Java" java NotesTool list # [1] Buy groceries # [2] Read chapter 9 of Effective Java java NotesTool search "java" # [2] Read chapter 9 of Effective Java java NotesTool delete 1 # Deleted: Buy groceries java NotesTool list # [1] Read chapter 9 of Effective Java

Design decisions and trade-offs

  • Plain text vs binary: Plain text is human-readable, trivially backed up with cp, and grep-able. The downside is that newline characters inside a note would corrupt the format — hence the sanitisation in add().
  • Append vs rewrite on add: Appending is fast and safe under power loss (existing lines are untouched). Rewriting the whole file on every add would be slower and riskier for large files.
  • Read-all on delete: Unavoidable with flat files. The trade-off is acknowledged explicitly — this pattern is fine for hundreds of notes; for thousands you would switch to a format like SQLite via JDBC.
  • UTF-8 everywhere: Explicitly passing StandardCharsets.UTF_8 to every read and write call makes the tool portable and predictable regardless of the JVM's default charset.
  • No try-with-resources needed here: Files.readAllLines, Files.writeString, and Files.write are convenience methods that open, use, and close the underlying channel internally. If you drop down to BufferedReader or FileWriter directly, try-with-resources is mandatory.

Summary

This project combined every tool from the tutorial: Path and Files for idiomatic NIO.2 access, explicit UTF-8 charset handling, StandardOpenOption.APPEND for efficient writes, the read-modify-write pattern for in-place deletion, and streams-based search. The design choices — plain text, one note per line, home-directory storage — are deliberate and each has a documented trade-off. That kind of reasoning is what separates a maintainable codebase from one that merely works.