From aa810522d8aea2a07e3472063bb156ed8b818a67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:02:47 +0000 Subject: [PATCH 1/2] Initial plan From 8b111b22674c84d172c3f74a9f61b52cf858e741 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:08:44 +0000 Subject: [PATCH 2/2] Add interactive front-end menu (shell) to notes_app CLI Agent-Logs-Url: https://github.com/ShockaHolmes/NoteForge/sessions/3d7019f2-b35b-4f23-a7e1-2946e175f52f Co-authored-by: ShockaHolmes <78627489+ShockaHolmes@users.noreply.github.com> --- python/src/notes_app/cli/shell.py | 189 +++++++++++++++++ python/tests/test_shell.py | 342 ++++++++++++++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 python/src/notes_app/cli/shell.py create mode 100644 python/tests/test_shell.py diff --git a/python/src/notes_app/cli/shell.py b/python/src/notes_app/cli/shell.py new file mode 100644 index 0000000..9909461 --- /dev/null +++ b/python/src/notes_app/cli/shell.py @@ -0,0 +1,189 @@ +"""Interactive shell (menu) for the notes application.""" + +import shlex +import sys +from collections.abc import Callable + +from notes_app.cli.commands.backup_command import run_backup +from notes_app.cli.commands.create_command import run_create +from notes_app.cli.commands.delete_command import run_delete +from notes_app.cli.commands.help_command import render_help +from notes_app.cli.commands.list_command import run_list +from notes_app.cli.commands.read_command import run_read +from notes_app.cli.commands.restore_command import run_restore +from notes_app.cli.commands.search_command import run_search +from notes_app.cli.commands.update_command import run_update +from notes_app.services.backup_service import BackupService +from notes_app.services.note_service import NoteService + +PROMPT = "notes> " +PROGRAM_NAME = "notes shell" + +SUPPORTED_COMMANDS = ( + "help", + "list", + "create", + "search", + "read", + "update", + "delete", + "backup", + "restore", + "quit", +) + + +def _show_help() -> None: + """Display help for all available commands.""" + print(render_help(PROGRAM_NAME)) + + +def _handle_command( + command: str, + args: list[str], + service: NoteService, + backup_service: BackupService | None, +) -> bool: + """Dispatch a single command. Returns False when the user requests quit.""" + if command == "quit": + return False + + if command == "help": + _show_help() + + elif command == "list": + print(run_list(service)) + + elif command == "create": + if len(args) < 2: + print("Error: create requires and <content>.") + print('Usage: create "Title" "Content"') + else: + title = args[0] + content = " ".join(args[1:]) + print(run_create(service, title=title, content=content)) + + elif command == "search": + if not args: + print("Error: search requires <query>.") + print("Usage: search <query>") + else: + query = " ".join(args) + print(run_search(service, query=query)) + + elif command == "read": + if not args: + print("Error: read requires <id>.") + print("Usage: read <id>") + else: + output, ok = run_read(service, note_id=args[0]) + if ok: + print(output) + else: + print(output, file=sys.stderr) + + elif command == "update": + if len(args) < 2: + print("Error: update requires <id> and at least one flag.") + print('Usage: update <id> [--title "..."] [--tags "tag1,tag2"] [--content "..."]') + else: + output, ok = run_update(service, note_id=args[0], args=list(args[1:])) + if ok: + print(output) + else: + print(output, file=sys.stderr) + + elif command == "delete": + if not args: + print("Error: delete requires <id>.") + print("Usage: delete <id>") + else: + output, ok = run_delete(service, note_id=args[0]) + if ok: + print(output) + else: + print(output, file=sys.stderr) + + elif command == "backup": + if backup_service is None: + print("Error: backup service is not available.", file=sys.stderr) + else: + output_dir = args[0] if args else None + output, ok = run_backup(backup_service, output_dir=output_dir) + if ok: + print(output) + else: + print(output, file=sys.stderr) + + elif command == "restore": + if backup_service is None: + print("Error: backup service is not available.", file=sys.stderr) + elif not args: + print("Error: restore requires <backup.zip>.") + print("Usage: restore <backup.zip>") + else: + output, ok = run_restore(backup_service, backup_path=args[0]) + if ok: + print(output) + else: + print(output, file=sys.stderr) + + else: + print(f"Unknown command: '{command}'") + print(f"Supported commands: {', '.join(SUPPORTED_COMMANDS)}") + print("Type 'help' for usage details.") + + return True + + +def run_shell( + service: NoteService, + backup_service: BackupService | None = None, + input_fn: Callable[[str], str] = input, +) -> None: + """Run the interactive notes shell until the user quits.""" + print("Future Proof Notes Manager - Interactive Shell") + print("Type 'help' for available commands, 'quit' to exit.") + print() + + while True: + try: + line = input_fn(PROMPT).strip() + except EOFError: + print() + break + except KeyboardInterrupt: + print("\nUse 'quit' to exit.") + continue + + if not line: + continue + + try: + parts = shlex.split(line) + except ValueError: + print("Error: could not parse input (unmatched quotes?).") + continue + + command = parts[0].lower() + args = parts[1:] + + keep_running = _handle_command(command, args, service, backup_service) + if not keep_running: + break + + +def main() -> int: + """Start the interactive notes shell.""" + from notes_app.cli.main import build_backup_service, build_service # local import avoids cycles + + service = build_service() + backup_service = build_backup_service() + + run_shell(service, backup_service=backup_service) + print("\nGoodbye!") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/tests/test_shell.py b/python/tests/test_shell.py new file mode 100644 index 0000000..8645665 --- /dev/null +++ b/python/tests/test_shell.py @@ -0,0 +1,342 @@ +from datetime import datetime, timezone +from io import StringIO + +import pytest + +from notes_app.cli.shell import SUPPORTED_COMMANDS, run_shell +from notes_app.models.note import Note +from notes_app.repositories.note_repository import NoteRepository +from notes_app.services.note_service import NoteService + + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + + +class InMemoryNoteRepository(NoteRepository): + def __init__(self, notes: list[Note] | None = None): + self._notes: list[Note] = list(notes or []) + + def save(self, note: Note) -> None: + self._notes = [n for n in self._notes if n.id != note.id] + self._notes.append(note) + + def list_notes(self) -> list[Note]: + return list(self._notes) + + def get_by_id(self, note_id: str) -> Note | None: + return next((n for n in self._notes if n.id == note_id), None) + + def delete_by_id(self, note_id: str) -> bool: + before = len(self._notes) + self._notes = [n for n in self._notes if n.id != note_id] + return len(self._notes) != before + + +def _make_note(note_id: str = "sample", title: str = "Sample Note") -> Note: + now = datetime(2026, 1, 1, tzinfo=timezone.utc) + return Note( + id=note_id, + title=title, + created=now, + modified=now, + tags=("demo",), + content="sample content", + ) + + +def _make_service(notes: list[Note] | None = None) -> NoteService: + return NoteService(InMemoryNoteRepository(notes)) + + +def _make_input_fn(lines: list[str]): + """Return an input function that yields successive lines then raises EOFError.""" + iterator = iter(lines) + + def _input(_prompt: str = "") -> str: + try: + return next(iterator) + except StopIteration: + raise EOFError + + return _input + + +# --------------------------------------------------------------------------- +# Tests: banner and welcome output +# --------------------------------------------------------------------------- + + +def test_shell_prints_welcome_banner(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["quit"])) + + out = capsys.readouterr().out + assert "Interactive Shell" in out + assert "help" in out + assert "quit" in out + + +# --------------------------------------------------------------------------- +# Tests: quit command +# --------------------------------------------------------------------------- + + +def test_shell_quit_exits_cleanly(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["quit"])) + + out = capsys.readouterr().out + # No crash, no leftover error + assert out is not None + + +def test_shell_eof_exits_cleanly(capsys) -> None: + # Empty input sequence triggers EOFError immediately + run_shell(_make_service(), input_fn=_make_input_fn([])) + # Should complete without raising + capsys.readouterr() + + +# --------------------------------------------------------------------------- +# Tests: help command +# --------------------------------------------------------------------------- + + +def test_shell_help_lists_all_commands(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["help", "quit"])) + + out = capsys.readouterr().out + assert "Commands:" in out + assert "create <title> <content>" in out + assert "list" in out + assert "search <query>" in out + assert "read <id>" in out + assert "update <id>" in out + assert "delete <id>" in out + + +# --------------------------------------------------------------------------- +# Tests: list command +# --------------------------------------------------------------------------- + + +def test_shell_list_with_notes(capsys) -> None: + service = _make_service([_make_note()]) + run_shell(service, input_fn=_make_input_fn(["list", "quit"])) + + out = capsys.readouterr().out + assert "Notes:" in out + assert "sample.md" in out + + +def test_shell_list_empty(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["list", "quit"])) + + out = capsys.readouterr().out + assert "No notes found." in out + + +# --------------------------------------------------------------------------- +# Tests: create command +# --------------------------------------------------------------------------- + + +def test_shell_create_note(capsys) -> None: + service = _make_service() + run_shell(service, input_fn=_make_input_fn(['create "Hello World" "Some content"', "quit"])) + + out = capsys.readouterr().out + assert "hello-world.md" in out + assert service.get_note("hello-world") is not None + + +def test_shell_create_missing_args_shows_error(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["create Title", "quit"])) + + out = capsys.readouterr().out + assert "Error: create requires" in out + + +# --------------------------------------------------------------------------- +# Tests: search command +# --------------------------------------------------------------------------- + + +def test_shell_search_finds_match(capsys) -> None: + service = _make_service([_make_note()]) + run_shell(service, input_fn=_make_input_fn(["search sample", "quit"])) + + out = capsys.readouterr().out + assert "match(es) found." in out + + +def test_shell_search_no_query_shows_error(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["search", "quit"])) + + out = capsys.readouterr().out + assert "Error: search requires" in out + + +# --------------------------------------------------------------------------- +# Tests: read command +# --------------------------------------------------------------------------- + + +def test_shell_read_existing_note(capsys) -> None: + service = _make_service([_make_note()]) + run_shell(service, input_fn=_make_input_fn(["read sample", "quit"])) + + out = capsys.readouterr().out + assert "title: Sample Note" in out + assert "sample content" in out + + +def test_shell_read_missing_note_shows_error(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["read nonexistent", "quit"])) + + err = capsys.readouterr().err + assert "not found" in err.lower() + + +def test_shell_read_missing_id_shows_error(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["read", "quit"])) + + out = capsys.readouterr().out + assert "Error: read requires" in out + + +# --------------------------------------------------------------------------- +# Tests: update command +# --------------------------------------------------------------------------- + + +def test_shell_update_note_title(capsys) -> None: + service = _make_service([_make_note()]) + run_shell( + service, + input_fn=_make_input_fn(['update sample --title "New Title"', "quit"]), + ) + + out = capsys.readouterr().out + assert "Updated note 'sample.md'" in out + updated = service.get_note("sample") + assert updated is not None + assert updated.title == "New Title" + + +def test_shell_update_missing_args_shows_error(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["update sample", "quit"])) + + out = capsys.readouterr().out + assert "Error: update requires" in out + + +# --------------------------------------------------------------------------- +# Tests: delete command +# --------------------------------------------------------------------------- + + +def test_shell_delete_note_after_confirmation(capsys) -> None: + service = _make_service([_make_note()]) + + # The delete command calls input() internally for confirmation. + # We need to supply 'y' for the inner confirmation prompt via confirm_fn. + # Since run_shell uses our input_fn for the shell prompt but run_delete + # uses its own confirm_fn (defaulting to built-in input), we monkeypatch + # delete_command in shell to pass confirm_fn. + import notes_app.cli.shell as shell_module + import notes_app.cli.commands.delete_command as delete_mod + + original_run_delete = shell_module.run_delete + + def patched_run_delete(svc, note_id): # type: ignore[misc] + return delete_mod.run_delete(svc, note_id=note_id, confirm_fn=lambda _p: "y") + + shell_module.run_delete = patched_run_delete # type: ignore[assignment] + try: + run_shell(service, input_fn=_make_input_fn(["delete sample", "quit"])) + finally: + shell_module.run_delete = original_run_delete + + out = capsys.readouterr().out + assert "Deleted note 'sample.md'" in out + assert service.get_note("sample") is None + + +def test_shell_delete_missing_id_shows_error(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["delete", "quit"])) + + out = capsys.readouterr().out + assert "Error: delete requires" in out + + +# --------------------------------------------------------------------------- +# Tests: unknown command +# --------------------------------------------------------------------------- + + +def test_shell_unknown_command_shows_hint(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["frobnicate", "quit"])) + + out = capsys.readouterr().out + assert "Unknown command: 'frobnicate'" in out + assert "help" in out + + +# --------------------------------------------------------------------------- +# Tests: empty input is ignored +# --------------------------------------------------------------------------- + + +def test_shell_empty_input_is_ignored(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(["", " ", "quit"])) + # Should complete without errors + capsys.readouterr() + + +# --------------------------------------------------------------------------- +# Tests: unmatched quotes produce error not crash +# --------------------------------------------------------------------------- + + +def test_shell_unmatched_quotes_gives_error(capsys) -> None: + run_shell(_make_service(), input_fn=_make_input_fn(['create "bad quote', "quit"])) + + out = capsys.readouterr().out + assert "could not parse input" in out + + +# --------------------------------------------------------------------------- +# Tests: keyboard interrupt +# --------------------------------------------------------------------------- + + +def test_shell_keyboard_interrupt_continues(capsys) -> None: + call_count = 0 + + def _input_raising(_prompt: str = "") -> str: + nonlocal call_count + call_count += 1 + if call_count == 1: + raise KeyboardInterrupt + return "quit" + + run_shell(_make_service(), input_fn=_input_raising) + + out = capsys.readouterr().out + assert "quit" in out.lower() + + +# --------------------------------------------------------------------------- +# Tests: SUPPORTED_COMMANDS includes all expected commands +# --------------------------------------------------------------------------- + + +def test_supported_commands_include_all_note_operations() -> None: + for cmd in ("help", "list", "create", "search", "read", "update", "delete", "quit"): + assert cmd in SUPPORTED_COMMANDS + + +def test_supported_commands_include_backup_and_restore() -> None: + assert "backup" in SUPPORTED_COMMANDS + assert "restore" in SUPPORTED_COMMANDS