From 8a7af94719544820bdaeb4e1aebf54ff18b78396 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:24:14 -0400 Subject: [PATCH] Add entry archiving features and update menus --- src/main.py | 14 +++-- src/password_manager/entry_management.py | 43 ++++++++++--- src/password_manager/manager.py | 78 +++++++++++++++++++++--- src/tests/test_cli_invalid_input.py | 2 +- 4 files changed, 113 insertions(+), 24 deletions(-) diff --git a/src/main.py b/src/main.py index 35f1298..47ab60f 100644 --- a/src/main.py +++ b/src/main.py @@ -276,9 +276,7 @@ def print_matches( print(color_text(f" Username: {username}", "index")) if url: print(color_text(f" URL: {url}", "index")) - print( - color_text(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "index") - ) + print(color_text(f" Archived: {'Yes' if blacklisted else 'No'}", "index")) print("-" * 40) @@ -752,6 +750,8 @@ def display_menu( 5. Modify an Existing Entry 6. 2FA Codes 7. Settings + 8. Archive Entry + 9. View Archived Entries """ display_fn = getattr(password_manager, "display_stats", None) if callable(display_fn): @@ -781,7 +781,7 @@ def display_menu( print(color_text(menu, "menu")) try: choice = timed_input( - "Enter your choice (1-7) or press Enter to exit: ", + "Enter your choice (1-9) or press Enter to exit: ", inactivity_timeout, ).strip() except TimeoutError: @@ -856,6 +856,12 @@ def display_menu( elif choice == "7": password_manager.update_activity() handle_settings(password_manager) + elif choice == "8": + password_manager.update_activity() + password_manager.handle_archive_entry() + elif choice == "9": + password_manager.update_activity() + password_manager.handle_view_archived_entries() else: print(colored("Invalid choice. Please select a valid option.", "red")) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 0268ac3..9d4a8ea 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -630,9 +630,17 @@ class EntryManager: self.modify_entry(index, archived=False) def list_entries( - self, sort_by: str = "index", filter_kind: str | None = None + self, + sort_by: str = "index", + filter_kind: str | None = None, + *, + include_archived: bool = False, ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: - """List entries in the index with optional sorting and filtering.""" + """List entries in the index with optional sorting and filtering. + + By default archived entries are omitted unless ``include_archived`` is + ``True``. + """ try: data = self.vault.load_index() entries_data = data.get("entries", {}) @@ -662,6 +670,10 @@ class EntryManager: != filter_kind ): continue + if not include_archived and entry.get( + "archived", entry.get("blacklisted", False) + ): + continue filtered_items.append((int(idx_str), entry)) entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] @@ -708,7 +720,7 @@ class EntryManager: print(colored(f" URL: {entry.get('url') or 'N/A'}", "cyan")) print( colored( - f" Blacklisted: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}", + f" Archived: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}", "cyan", ) ) @@ -880,11 +892,19 @@ class EntryManager: ) def list_all_entries( - self, sort_by: str = "index", filter_kind: str | None = None + self, + sort_by: str = "index", + filter_kind: str | None = None, + *, + include_archived: bool = False, ) -> None: """Display all entries using :meth:`list_entries`.""" try: - entries = self.list_entries(sort_by=sort_by, filter_kind=filter_kind) + entries = self.list_entries( + sort_by=sort_by, + filter_kind=filter_kind, + include_archived=include_archived, + ) if not entries: print(colored("No entries to display.", "yellow")) return @@ -896,9 +916,7 @@ class EntryManager: print(colored(f" Label: {website}", "cyan")) print(colored(f" Username: {username or 'N/A'}", "cyan")) print(colored(f" URL: {url or 'N/A'}", "cyan")) - print( - colored(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "cyan") - ) + print(colored(f" Archived: {'Yes' if blacklisted else 'No'}", "cyan")) print("-" * 40) except Exception as e: @@ -907,7 +925,10 @@ class EntryManager: return def get_entry_summaries( - self, filter_kind: str | None = None + self, + filter_kind: str | None = None, + *, + include_archived: bool = False, ) -> list[tuple[int, str, str]]: """Return a list of entry index, type, and display labels.""" try: @@ -919,6 +940,10 @@ class EntryManager: etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) if filter_kind and etype != filter_kind: continue + if not include_archived and entry.get( + "archived", entry.get("blacklisted", False) + ): + continue if etype == EntryType.PASSWORD.value: label = entry.get("label", entry.get("website", "")) else: diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 91939be..9d26481 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1561,7 +1561,7 @@ class PasswordManager: if blacklisted: print( colored( - f"Warning: This password is blacklisted and should not be used.", + f"Warning: This password is archived and should not be used.", "yellow", ) ) @@ -1589,7 +1589,7 @@ class PasswordManager: print(colored(f"Associated URL: {url or 'N/A'}", "cyan")) print( colored( - f"Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", + f"Archived Status: {'Archived' if blacklisted else 'Active'}", "cyan", ) ) @@ -1673,7 +1673,7 @@ class PasswordManager: print(colored(f"Current Digits: {digits}", "cyan")) print( colored( - f"Current Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", + f"Current Archived Status: {'Archived' if blacklisted else 'Active'}", "cyan", ) ) @@ -1708,7 +1708,7 @@ class PasswordManager: ) blacklist_input = ( input( - f'Is this 2FA code blacklisted? (Y/N, current: {"Y" if blacklisted else "N"}): ' + f'Archive this 2FA code? (Y/N, current: {"Y" if blacklisted else "N"}): ' ) .strip() .lower() @@ -1722,7 +1722,7 @@ class PasswordManager: else: print( colored( - "Invalid input for blacklist status. Keeping the current status.", + "Invalid input for archived status. Keeping the current status.", "yellow", ) ) @@ -1776,7 +1776,7 @@ class PasswordManager: print(colored(f"Current URL: {url or 'N/A'}", "cyan")) print( colored( - f"Current Blacklist Status: {'Blacklisted' if blacklisted else 'Not Blacklisted'}", + f"Current Archived Status: {'Archived' if blacklisted else 'Active'}", "cyan", ) ) @@ -1802,7 +1802,7 @@ class PasswordManager: ) blacklist_input = ( input( - f'Is this password blacklisted? (Y/N, current: {"Y" if blacklisted else "N"}): ' + f'Archive this password? (Y/N, current: {"Y" if blacklisted else "N"}): ' ) .strip() .lower() @@ -1816,7 +1816,7 @@ class PasswordManager: else: print( colored( - "Invalid input for blacklist status. Keeping the current status.", + "Invalid input for archived status. Keeping the current status.", "yellow", ) ) @@ -1997,7 +1997,10 @@ class PasswordManager: print(color_text(f" Username: {username or 'N/A'}", "index")) print(color_text(f" URL: {url or 'N/A'}", "index")) print( - color_text(f" Blacklisted: {'Yes' if blacklisted else 'No'}", "index") + color_text( + f" Archived: {'Yes' if blacklisted else 'No'}", + "index", + ) ) print("-" * 40) @@ -2038,7 +2041,9 @@ class PasswordManager: print(colored("Invalid choice.", "red")) continue - summaries = self.entry_manager.get_entry_summaries(filter_kind) + summaries = self.entry_manager.get_entry_summaries( + filter_kind, include_archived=False + ) if not summaries: continue while True: @@ -2103,6 +2108,59 @@ class PasswordManager: logging.error(f"Error during entry deletion: {e}", exc_info=True) print(colored(f"Error: Failed to delete entry: {e}", "red")) + def handle_archive_entry(self) -> None: + """Archive an entry without deleting it.""" + try: + index_input = input( + "Enter the index number of the entry to archive: " + ).strip() + if not index_input.isdigit(): + print(colored("Error: Index must be a number.", "red")) + return + index = int(index_input) + self.entry_manager.archive_entry(index) + self.is_dirty = True + self.last_update = time.time() + except Exception as e: + logging.error(f"Error archiving entry: {e}", exc_info=True) + print(colored(f"Error: Failed to archive entry: {e}", "red")) + + def handle_view_archived_entries(self) -> None: + """Display archived entries and optionally restore one.""" + try: + archived = self.entry_manager.list_entries(include_archived=True) + archived = [e for e in archived if e[4]] + if not archived: + print(colored("No archived entries found.", "yellow")) + return + while True: + clear_and_print_fingerprint( + getattr(self, "current_fingerprint", None), + "Main Menu > Archived Entries", + ) + print(colored("\n[+] Archived Entries:\n", "green")) + for idx, label, username, url, _ in archived: + print(colored(f"{idx}. {label}", "cyan")) + idx_input = input( + "Enter index to restore or press Enter to go back: " + ).strip() + if not idx_input: + break + if not idx_input.isdigit(): + print(colored("Invalid index.", "red")) + continue + restore_index = int(idx_input) + self.entry_manager.restore_entry(restore_index) + self.is_dirty = True + self.last_update = time.time() + archived = [e for e in archived if e[0] != restore_index] + if not archived: + print(colored("All entries restored.", "green")) + break + except Exception as e: + logging.error(f"Error viewing archived entries: {e}", exc_info=True) + print(colored(f"Error: Failed to view archived entries: {e}", "red")) + def handle_display_totp_codes(self) -> None: """Display all stored TOTP codes with a countdown progress bar.""" try: diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index 6331c8c..cfdf2bb 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -64,7 +64,7 @@ def test_empty_and_non_numeric_choice(monkeypatch, capsys): def test_out_of_range_menu(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["9", ""]) + inputs = iter(["10", ""]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) with pytest.raises(SystemExit): main.display_menu(pm, sync_interval=1000, inactivity_timeout=1000)