Files
trace/trace/tui.py
overcuriousity e37597c315 bug fixes
2025-12-11 23:07:19 +01:00

2420 lines
100 KiB
Python

import curses
import time
from typing import Optional, List
from .models import Case, Evidence, Note
from .storage import Storage, StateManager
class TUI:
def __init__(self, stdscr):
self.stdscr = stdscr
self.storage = Storage()
self.state_manager = StateManager()
self.current_view = "case_list" # case_list, case_detail, evidence_detail, tags_list, tag_notes_list, note_detail, ioc_list, ioc_notes_list, help
self.selected_index = 0
self.scroll_offset = 0 # Index of the first item to display
self.cases = self.storage.cases
# State for navigation
self.active_case: Optional[Case] = None
self.active_evidence: Optional[Evidence] = None
# State for tags view
self.current_tags = [] # List of (tag, count) tuples
self.current_tag = None # Currently selected tag
self.tag_notes = [] # Notes with the current tag
self.current_note = None # Currently viewed note in detail
# State for IOC view
self.current_iocs = [] # List of (ioc, count, type) tuples
self.current_ioc = None # Currently selected IOC
self.ioc_notes = [] # Notes with the current IOC
# Filtering
self.filter_mode = False
self.filter_query = ""
# Flash Message
self.flash_message = ""
self.flash_time = 0
# UI Config
curses.curs_set(0) # Hide cursor
curses.start_color()
if curses.has_colors():
# Selection / Highlight
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN)
# Success / Active indicators
curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK)
# Info / Warnings
curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK)
# Errors / Critical / IOCs
curses.init_pair(4, curses.COLOR_RED, curses.COLOR_BLACK)
# Headers / Titles (bright cyan)
curses.init_pair(5, curses.COLOR_CYAN, curses.COLOR_BLACK)
# Metadata / Secondary text (dim)
curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_BLACK)
# Borders / Separators (blue)
curses.init_pair(7, curses.COLOR_BLUE, curses.COLOR_BLACK)
# Tags (magenta)
curses.init_pair(8, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
self.height, self.width = stdscr.getmaxyx()
# Load initial active state
active_state = self.state_manager.get_active()
self.global_active_case_id = active_state.get("case_id")
self.global_active_evidence_id = active_state.get("evidence_id")
def run(self):
while True:
self.height, self.width = self.stdscr.getmaxyx()
self.stdscr.clear()
self.draw_header()
self.draw_status_bar()
# Content area bounds
self.content_y = 2
self.content_h = self.height - 4 # Reserve top 2, bottom 2
if self.current_view == "case_list":
self.draw_case_list()
elif self.current_view == "case_detail":
self.draw_case_detail()
elif self.current_view == "evidence_detail":
self.draw_evidence_detail()
elif self.current_view == "tags_list":
self.draw_tags_list()
elif self.current_view == "tag_notes_list":
self.draw_tag_notes_list()
elif self.current_view == "ioc_list":
self.draw_ioc_list()
elif self.current_view == "ioc_notes_list":
self.draw_ioc_notes_list()
elif self.current_view == "note_detail":
self.draw_note_detail()
elif self.current_view == "help":
self.draw_help()
self.stdscr.refresh()
key = self.stdscr.getch()
if not self.handle_input(key):
break
def show_message(self, msg):
self.flash_message = msg
self.flash_time = time.time()
def _get_all_tags_with_counts(self, notes):
"""Get all tags from notes with their occurrence counts"""
tag_counts = {}
for note in notes:
for tag in note.tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
# Sort by count (descending), then alphabetically
sorted_tags = sorted(tag_counts.items(), key=lambda x: (-x[1], x[0]))
return sorted_tags # Returns list of (tag, count) tuples
def _get_notes_with_tag(self, notes, tag):
"""Get all notes containing a specific tag (case-insensitive)"""
tag_lower = tag.lower()
return [note for note in notes if tag_lower in note.tags]
def _get_all_iocs_with_counts(self, notes):
"""Get all IOCs from notes with their occurrence counts and types"""
ioc_data = {} # ioc -> (count, type)
for note in notes:
for ioc in note.iocs:
if ioc not in ioc_data:
# Determine IOC type
ioc_type = self._classify_ioc(ioc)
ioc_data[ioc] = [1, ioc_type]
else:
ioc_data[ioc][0] += 1
# Sort by count (descending), then alphabetically
sorted_iocs = sorted(ioc_data.items(), key=lambda x: (-x[1][0], x[0]))
# Return list of (ioc, count, type) tuples
return [(ioc, count, ioc_type) for ioc, (count, ioc_type) in sorted_iocs]
def _classify_ioc(self, ioc):
"""Classify IOC type based on pattern"""
import re
if re.match(r'^[a-fA-F0-9]{32}$', ioc):
return 'MD5'
elif re.match(r'^[a-fA-F0-9]{40}$', ioc):
return 'SHA1'
elif re.match(r'^[a-fA-F0-9]{64}$', ioc):
return 'SHA256'
elif re.match(r'^https?://', ioc):
return 'URL'
elif '@' in ioc:
return 'EMAIL'
elif re.match(r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$', ioc):
return 'IPv4'
elif ':' in ioc and any(c in '0123456789abcdefABCDEF' for c in ioc):
return 'IPv6'
else:
return 'DOMAIN'
def _get_notes_with_ioc(self, notes, ioc):
"""Get all notes containing a specific IOC"""
return [note for note in notes if ioc in note.iocs]
def _get_context_notes(self):
"""Get all notes from the current context (case or evidence)"""
if self.active_evidence:
# Evidence context - only evidence notes
return list(self.active_evidence.notes)
elif self.active_case:
# Case context - case notes + all evidence notes
all_notes = list(self.active_case.notes)
for ev in self.active_case.evidence:
all_notes.extend(ev.notes)
return all_notes
return []
def handle_open_tags(self):
"""Open the tags list view for the current context"""
if self.current_view not in ["case_detail", "evidence_detail"]:
return
# Get all notes from current context
all_notes = self._get_context_notes()
if not all_notes:
self.show_message("No notes found in current context.")
return
# Get tags sorted by count
self.current_tags = self._get_all_tags_with_counts(all_notes)
if not self.current_tags:
self.show_message("No tags found in notes.")
return
# Switch to tags list view
self.current_view = "tags_list"
self.selected_index = 0
self.scroll_offset = 0
def handle_open_iocs(self):
"""Open the IOCs list view for the current context"""
if self.current_view not in ["case_detail", "evidence_detail"]:
return
# Get all notes from current context
all_notes = self._get_context_notes()
if not all_notes:
self.show_message("No notes found in current context.")
return
# Get IOCs sorted by count
self.current_iocs = self._get_all_iocs_with_counts(all_notes)
if not self.current_iocs:
self.show_message("No IOCs found in notes.")
return
# Switch to IOCs list view
self.current_view = "ioc_list"
self.selected_index = 0
self.scroll_offset = 0
def _safe_truncate(self, text, max_width, ellipsis="..."):
"""
Safely truncate text to fit within max_width, handling Unicode characters.
Uses a conservative approach to avoid curses display errors.
"""
if not text:
return text
# Try to fit the text as-is
if len(text) <= max_width:
return text
# Need to truncate - account for ellipsis
if max_width <= len(ellipsis):
return ellipsis[:max_width]
# Truncate conservatively (character by character) to handle multi-byte UTF-8
target_len = max_width - len(ellipsis)
truncated = text[:target_len]
# Encode and check actual byte length to be safe with UTF-8
# If it's too long, trim further
while len(truncated) > 0:
try:
# Test if this will fit when displayed
test_str = truncated + ellipsis
if len(test_str) <= max_width:
return test_str
except:
pass
# Trim one more character
truncated = truncated[:-1]
return ellipsis[:max_width]
def draw_header(self):
# Modern header with icon and better styling
title = "◆ trace"
subtitle = "Forensic Investigation Notes"
# Top border line
try:
self.stdscr.attron(curses.color_pair(7))
self.stdscr.addstr(0, 0, "" * self.width)
self.stdscr.attroff(curses.color_pair(7))
except curses.error:
pass
# Title line with gradient effect
try:
# Icon and main title
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD)
self.stdscr.addstr(0, 2, title)
self.stdscr.attroff(curses.color_pair(5) | curses.A_BOLD)
# Subtitle
self.stdscr.attron(curses.color_pair(6))
self.stdscr.addstr(0, 2 + len(title) + 2, subtitle)
self.stdscr.attroff(curses.color_pair(6))
except curses.error:
pass
def draw_status_bar(self):
# Determine status text
status_text = ""
attr = curses.color_pair(1)
# Check for flash message (display for 3 seconds)
icon = ""
if self.flash_message and (time.time() - self.flash_time < 3):
if "Failed" in self.flash_message or "Error" in self.flash_message:
icon = ""
attr = curses.color_pair(4) # Red
else:
icon = ""
attr = curses.color_pair(2) # Green
status_text = f"{icon} {self.flash_message}"
elif self.filter_mode:
icon = ""
status_text = f"{icon} Filter: {self.filter_query}"
attr = curses.color_pair(3)
else:
# Active context display
if self.global_active_case_id:
c = self.storage.get_case(self.global_active_case_id)
if c:
icon = ""
status_text = f"{icon} {c.case_number}"
attr = curses.color_pair(2) # Green for active
if self.global_active_evidence_id:
_, ev = self.storage.find_evidence(self.global_active_evidence_id)
if ev:
status_text += f"{ev.name}"
else:
icon = ""
status_text = f"{icon} No active context"
attr = curses.color_pair(6) | curses.A_DIM
# Truncate if too long
max_status_len = self.width - 2
if len(status_text) > max_status_len:
status_text = status_text[:max_status_len-1] + ""
# Bottom line with border
try:
# Border line above status
self.stdscr.attron(curses.color_pair(7))
self.stdscr.addstr(self.height - 2, 0, "" * self.width)
self.stdscr.attroff(curses.color_pair(7))
# Status text
self.stdscr.attron(attr)
self.stdscr.addstr(self.height - 1, 1, status_text)
remaining = self.width - len(status_text) - 2
if remaining > 0:
self.stdscr.addstr(self.height - 1, len(status_text) + 1, " " * remaining)
self.stdscr.attroff(attr)
except curses.error:
pass # Ignore bottom-right corner write errors
def _update_scroll(self, total_items):
# Viewport height calculation (approximate lines available for list)
list_h = self.content_h - 2 # Title + padding
if list_h < 1: list_h = 1
# Ensure selected index is visible
if self.selected_index < self.scroll_offset:
self.scroll_offset = self.selected_index
elif self.selected_index >= self.scroll_offset + list_h:
self.scroll_offset = self.selected_index - list_h + 1
return list_h
def _get_filtered_list(self, items, key_attr=None, key_attr2=None):
if not self.filter_query:
return items
q = self.filter_query.lower()
filtered = []
for item in items:
# Check primary attribute
val1 = getattr(item, key_attr, "") if key_attr else ""
val2 = getattr(item, key_attr2, "") if key_attr2 else ""
if q in str(val1).lower() or q in str(val2).lower():
filtered.append(item)
return filtered
def draw_case_list(self):
# Header with icon
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD)
self.stdscr.addstr(2, 2, "■ Cases")
self.stdscr.attroff(curses.color_pair(5) | curses.A_BOLD)
if not self.cases:
self.stdscr.attron(curses.color_pair(3))
self.stdscr.addstr(4, 4, "┌─ No cases found")
self.stdscr.addstr(5, 4, "└─ Press 'N' to create your first case")
self.stdscr.attroff(curses.color_pair(3))
self.stdscr.addstr(self.height - 3, 2, "[N] New Case [q] Quit", curses.color_pair(3))
return
display_cases = self._get_filtered_list(self.cases, "case_number", "name")
# Show count
self.stdscr.attron(curses.color_pair(6) | curses.A_DIM)
self.stdscr.addstr(2, 12, f"({len(display_cases)} total)")
self.stdscr.attroff(curses.color_pair(6) | curses.A_DIM)
list_h = self._update_scroll(len(display_cases))
for i in range(list_h):
idx = self.scroll_offset + i
if idx >= len(display_cases):
break
case = display_cases[idx]
y = 4 + i
# Calculate total note count and tags (case notes + all evidence notes)
all_notes = list(case.notes)
for ev in case.evidence:
all_notes.extend(ev.notes)
total_notes = len(all_notes)
# Count unique tags
all_tags = self._get_all_tags_with_counts(all_notes)
tag_count = len(all_tags)
# Active indicator with better icon
is_active = case.case_id == self.global_active_case_id and not self.global_active_evidence_id
prefix = "" if is_active else ""
# Build display string
display_str = f"{prefix}{case.case_number}"
if case.name:
display_str += f"{case.name}"
# Metadata indicators with icons
metadata = []
if len(case.evidence) > 0:
metadata.append(f"{len(case.evidence)} ev")
if total_notes > 0:
metadata.append(f"{total_notes}")
if tag_count > 0:
metadata.append(f"# {tag_count}")
if metadata:
display_str += "" + " ".join(metadata)
# Truncate safely for Unicode
display_str = self._safe_truncate(display_str, self.width - 6)
if idx == self.selected_index:
# Highlighted selection
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(y, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
# Normal item - color the active indicator if active
if is_active:
self.stdscr.attron(curses.color_pair(2) | curses.A_BOLD)
self.stdscr.addstr(y, 4, prefix)
self.stdscr.attroff(curses.color_pair(2) | curses.A_BOLD)
# Rest of line in normal color
self.stdscr.addstr(display_str[len(prefix):])
else:
self.stdscr.addstr(y, 4, display_str)
if not display_cases and self.cases:
self.stdscr.attron(curses.color_pair(3))
self.stdscr.addstr(4, 4, "┌─ No cases match filter")
self.stdscr.addstr(5, 4, "└─ Press ESC to clear filter")
self.stdscr.attroff(curses.color_pair(3))
self.stdscr.addstr(self.height - 3, 2, "[N] New Case [n] Add Note [Enter] Select [a] Active [d] Delete [/] Filter [s] Settings [?] Help", curses.color_pair(3))
def draw_case_detail(self):
if not self.active_case: return
case_note_count = len(self.active_case.notes)
# Header with case info
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD)
self.stdscr.addstr(2, 2, f"{self.active_case.case_number}")
self.stdscr.attroff(curses.color_pair(5) | curses.A_BOLD)
if self.active_case.name:
self.stdscr.attron(curses.color_pair(6))
self.stdscr.addstr(f"{self.active_case.name}")
self.stdscr.attroff(curses.color_pair(6))
# Metadata section
y_pos = 3
if self.active_case.investigator:
self.stdscr.attron(curses.color_pair(6) | curses.A_DIM)
self.stdscr.addstr(y_pos, 4, f"◆ Investigator:")
self.stdscr.attroff(curses.color_pair(6) | curses.A_DIM)
self.stdscr.addstr(f" {self.active_case.investigator}")
y_pos += 1
self.stdscr.attron(curses.color_pair(6) | curses.A_DIM)
self.stdscr.addstr(y_pos, 4, f"◆ Case Notes:")
self.stdscr.attroff(curses.color_pair(6) | curses.A_DIM)
note_color = curses.color_pair(2) if case_note_count > 0 else curses.color_pair(6)
self.stdscr.attron(note_color)
self.stdscr.addstr(f" {case_note_count}")
self.stdscr.attroff(note_color)
y_pos += 1
# Split screen between case notes and evidence
# Allocate space: half for case notes, half for evidence (if both exist)
available_space = self.content_h - 5
case_notes = self.active_case.notes
evidence_list = self._get_filtered_list(self.active_case.evidence, "name", "description")
# Determine context: are we selecting notes or evidence?
# If there are case notes, treat indices 0 to len(notes)-1 as notes
# If there is evidence, treat indices len(notes) to len(notes)+len(evidence)-1 as evidence
total_items = len(case_notes) + len(evidence_list)
# Determine what's selected
selecting_note = self.selected_index < len(case_notes)
# Case Notes section
if case_notes:
if y_pos < self.height - 3:
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD)
self.stdscr.addstr(y_pos, 2, "▪ Case Notes")
self.stdscr.attroff(curses.color_pair(5) | curses.A_BOLD)
self.stdscr.attron(curses.color_pair(6) | curses.A_DIM)
self.stdscr.addstr(y_pos, 16, f"({len(case_notes)} notes)")
self.stdscr.attroff(curses.color_pair(6) | curses.A_DIM)
y_pos += 1
# Calculate space for case notes
notes_space = min(len(case_notes), available_space // 2) if evidence_list else available_space
# Update scroll position if needed
self._update_scroll(total_items)
# Display notes
for i in range(notes_space):
note_idx = self.scroll_offset + i
if note_idx >= len(case_notes):
break
note = case_notes[note_idx]
y = y_pos + 1 + i
# Check if we're out of bounds
if y >= self.height - 3:
break
# Format note content
note_content = note.content.replace('\n', ' ').replace('\r', ' ')
display_str = f"- {note_content}"
display_str = self._safe_truncate(display_str, self.width - 6)
# Highlight if selected
if note_idx == self.selected_index:
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(y, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
self.stdscr.addstr(y, 4, display_str)
y_pos += notes_space + 1
# Evidence section header
y_pos += 1
if y_pos < self.height - 3:
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD)
self.stdscr.addstr(y_pos, 2, "▪ Evidence")
self.stdscr.attroff(curses.color_pair(5) | curses.A_BOLD)
# Show count
self.stdscr.attron(curses.color_pair(6) | curses.A_DIM)
self.stdscr.addstr(y_pos, 14, f"({len(evidence_list)} items)")
self.stdscr.attroff(curses.color_pair(6) | curses.A_DIM)
y_pos += 1
if not evidence_list:
# Check if we have space to display the message
if y_pos + 2 < self.height - 2:
self.stdscr.attron(curses.color_pair(3))
self.stdscr.addstr(y_pos + 1, 4, "┌─ No evidence items")
self.stdscr.addstr(y_pos + 2, 4, "└─ Press 'N' to add evidence")
self.stdscr.attroff(curses.color_pair(3))
else:
# Scrolling for evidence list
# Calculate remaining space
remaining_space = self.content_h - (y_pos - 2)
list_h = max(1, remaining_space)
self._update_scroll(total_items)
for i in range(list_h):
# Evidence indices start after case notes
evidence_idx = self.scroll_offset + i - len(case_notes)
if evidence_idx < 0 or evidence_idx >= len(evidence_list):
continue
ev = evidence_list[evidence_idx]
y = y_pos + 2 + i
if y >= self.height - 3: # Don't overflow into status bar
break
note_count = len(ev.notes)
# Count tags
ev_tags = self._get_all_tags_with_counts(ev.notes)
tag_count = len(ev_tags)
# Count IOCs
ev_iocs = self._get_all_iocs_with_counts(ev.notes)
ioc_count = len(ev_iocs)
# Active indicator
is_active = ev.evidence_id == self.global_active_evidence_id
prefix = "" if is_active else ""
# Build display string
display_str = f"{prefix}{ev.name}"
# Metadata with icons
metadata = []
if note_count > 0:
metadata.append(f"{note_count}")
if tag_count > 0:
metadata.append(f"# {tag_count}")
if ioc_count > 0:
metadata.append(f"{ioc_count}")
# Add hash indicator if source hash exists
source_hash = ev.metadata.get("source_hash")
if source_hash:
hash_preview = source_hash[:6] + ""
metadata.append(f"{hash_preview}")
if metadata:
display_str += "" + " ".join(metadata)
# Truncate safely
base_display = self._safe_truncate(display_str, self.width - 6)
# Check if this evidence item is selected
item_idx = len(case_notes) + evidence_idx
if item_idx == self.selected_index:
# Highlighted selection
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(y, 4, base_display)
self.stdscr.attroff(curses.color_pair(1))
else:
# Normal item - highlight active indicator if active
if is_active:
self.stdscr.attron(curses.color_pair(2) | curses.A_BOLD)
self.stdscr.addstr(y, 4, prefix)
self.stdscr.attroff(curses.color_pair(2) | curses.A_BOLD)
# Rest in normal, but highlight IOC warning in red
rest_of_line = base_display[len(prefix):]
if ioc_count > 0 and "" in rest_of_line:
# Split and color the IOC part
parts = rest_of_line.split("")
self.stdscr.addstr(parts[0])
self.stdscr.attron(curses.color_pair(4))
self.stdscr.addstr("" + parts[1])
self.stdscr.attroff(curses.color_pair(4))
else:
self.stdscr.addstr(rest_of_line)
else:
# Not active - still highlight IOC warning
if ioc_count > 0 and "" in base_display:
parts = base_display.split("")
self.stdscr.addstr(y, 4, parts[0])
self.stdscr.attron(curses.color_pair(4))
self.stdscr.addstr("" + parts[1])
self.stdscr.attroff(curses.color_pair(4))
else:
self.stdscr.addstr(y, 4, base_display)
self.stdscr.addstr(self.height - 3, 2, "[N] New Evidence [n] Add Note [t] Tags [i] IOCs [v] View Notes [a] Active [d] Delete [?] Help", curses.color_pair(3))
def draw_evidence_detail(self):
if not self.active_evidence: return
current_y = 2
self.stdscr.addstr(current_y, 2, f"Evidence: {self.active_evidence.name}", curses.A_BOLD)
current_y += 1
self.stdscr.addstr(current_y, 2, f"Desc: {self.active_evidence.description}")
current_y += 1
# Display source hash if available
source_hash = self.active_evidence.metadata.get("source_hash")
if source_hash:
# Truncate hash if too long for display
hash_display = self._safe_truncate(source_hash, self.width - 20)
self.stdscr.addstr(current_y, 2, f"Source Hash: {hash_display}", curses.color_pair(3))
current_y += 1
# Count and display IOCs
ev_iocs = self._get_all_iocs_with_counts(self.active_evidence.notes)
ioc_count = len(ev_iocs)
if ioc_count > 0:
ioc_display = f"({ioc_count} IOCs detected)"
self.stdscr.attron(curses.color_pair(4)) # Red
self.stdscr.addstr(current_y, 2, ioc_display)
self.stdscr.attroff(curses.color_pair(4))
current_y += 1
current_y += 1 # Blank line before notes
self.stdscr.addstr(current_y, 2, f"Notes ({len(self.active_evidence.notes)}):", curses.A_UNDERLINE)
current_y += 1
# Just show last N notes that fit
list_h = self.content_h - (current_y - 2) # Adjust for dynamic header
start_y = current_y
notes = self.active_evidence.notes
display_notes = notes[-list_h:] if len(notes) > list_h else notes
# Update scroll for note selection
if display_notes:
self._update_scroll(len(display_notes))
for i, note in enumerate(display_notes):
idx = self.scroll_offset + i
if idx >= len(display_notes):
break
note = display_notes[idx]
# Replace newlines with spaces for single-line display
note_content = note.content.replace('\n', ' ').replace('\r', ' ')
display_str = f"- {note_content}"
# Truncate safely for Unicode
display_str = self._safe_truncate(display_str, self.width - 6)
# Highlight selected note
if idx == self.selected_index:
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(start_y + i, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
self.stdscr.addstr(start_y + i, 4, display_str)
self.stdscr.addstr(self.height - 3, 2, "[n] Add Note [t] Tags [i] IOCs [v] View Notes [a] Active [d] Delete Note [?] Help", curses.color_pair(3))
def draw_tags_list(self):
"""Draw the tags list view showing all tags sorted by occurrence count"""
context = "Case" if self.current_view == "tags_list" and self.active_case else "Evidence"
context_name = self.active_case.case_number if self.active_case else (self.active_evidence.name if self.active_evidence else "")
self.stdscr.addstr(2, 2, f"Tags for {context}: {context_name}", curses.A_BOLD)
self.stdscr.addstr(3, 2, "" * (self.width - 4))
if not self.current_tags:
self.stdscr.addstr(5, 4, "No tags found.", curses.color_pair(3))
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3))
return
list_h = self._update_scroll(len(self.current_tags))
for i in range(list_h):
idx = self.scroll_offset + i
if idx >= len(self.current_tags):
break
tag, count = self.current_tags[idx]
y = 5 + i
display_str = f"#{tag}".ljust(30) + f"({count} notes)"
display_str = self._safe_truncate(display_str, self.width - 6)
if idx == self.selected_index:
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(y, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
self.stdscr.addstr(y, 4, display_str)
self.stdscr.addstr(self.height - 3, 2, "[Enter] View Notes [b] Back", curses.color_pair(3))
def draw_tag_notes_list(self):
"""Draw compact list of notes containing the selected tag"""
self.stdscr.addstr(2, 2, f"Notes tagged with #{self.current_tag} ({len(self.tag_notes)})", curses.A_BOLD)
self.stdscr.addstr(3, 2, "" * (self.width - 4))
if not self.tag_notes:
self.stdscr.addstr(5, 4, "No notes found.", curses.color_pair(3))
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3))
return
list_h = self._update_scroll(len(self.tag_notes))
for i in range(list_h):
idx = self.scroll_offset + i
if idx >= len(self.tag_notes):
break
note = self.tag_notes[idx]
y = 5 + i
timestamp_str = time.ctime(note.timestamp)
# Replace newlines for compact display
content_preview = note.content.replace('\n', ' ').replace('\r', ' ')
if len(content_preview) > 50:
content_preview = content_preview[:50] + "..."
display_str = f"[{timestamp_str}] {content_preview}"
display_str = self._safe_truncate(display_str, self.width - 6)
if idx == self.selected_index:
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(y, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
self.stdscr.addstr(y, 4, display_str)
self.stdscr.addstr(self.height - 3, 2, "[Enter] Expand [b] Back", curses.color_pair(3))
def draw_ioc_list(self):
"""Draw the IOC list view showing all IOCs sorted by occurrence count"""
context = "Case" if self.current_view == "ioc_list" and self.active_case else "Evidence"
context_name = self.active_case.case_number if self.active_case else (self.active_evidence.name if self.active_evidence else "")
self.stdscr.addstr(2, 2, f"IOCs for {context}: {context_name}", curses.A_BOLD)
self.stdscr.addstr(3, 2, "" * (self.width - 4))
if not self.current_iocs:
self.stdscr.addstr(5, 4, "No IOCs found.", curses.color_pair(3))
self.stdscr.addstr(self.height - 3, 2, "[b] Back [e] Export", curses.color_pair(3))
return
list_h = self._update_scroll(len(self.current_iocs))
for i in range(list_h):
idx = self.scroll_offset + i
if idx >= len(self.current_iocs):
break
ioc, count, ioc_type = self.current_iocs[idx]
y = 5 + i
# Show IOC with type indicator and count in red
display_str = f"{ioc} [{ioc_type}]".ljust(50) + f"({count} notes)"
display_str = self._safe_truncate(display_str, self.width - 6)
if idx == self.selected_index:
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(y, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
# Use red color for IOCs
self.stdscr.attron(curses.color_pair(4))
self.stdscr.addstr(y, 4, display_str)
self.stdscr.attroff(curses.color_pair(4))
self.stdscr.addstr(self.height - 3, 2, "[Enter] View Notes [e] Export [b] Back", curses.color_pair(3))
def draw_ioc_notes_list(self):
"""Draw compact list of notes containing the selected IOC"""
self.stdscr.addstr(2, 2, f"Notes with IOC: {self.current_ioc} ({len(self.ioc_notes)})", curses.A_BOLD)
self.stdscr.addstr(3, 2, "" * (self.width - 4))
if not self.ioc_notes:
self.stdscr.addstr(5, 4, "No notes found.", curses.color_pair(3))
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3))
return
list_h = self._update_scroll(len(self.ioc_notes))
for i in range(list_h):
idx = self.scroll_offset + i
if idx >= len(self.ioc_notes):
break
note = self.ioc_notes[idx]
y = 5 + i
timestamp_str = time.ctime(note.timestamp)
content_preview = note.content[:60].replace('\n', ' ') + "..." if len(note.content) > 60 else note.content.replace('\n', ' ')
display_str = f"[{timestamp_str}] {content_preview}"
display_str = self._safe_truncate(display_str, self.width - 6)
if idx == self.selected_index:
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(y, 4, display_str)
self.stdscr.attroff(curses.color_pair(1))
else:
self.stdscr.addstr(y, 4, display_str)
self.stdscr.addstr(self.height - 3, 2, "[Enter] Expand [b] Back", curses.color_pair(3))
def draw_note_detail(self):
"""Draw expanded view of a single note with all details"""
if not self.current_note:
return
self.stdscr.addstr(2, 2, "Note Details", curses.A_BOLD)
self.stdscr.addstr(3, 2, "" * (self.width - 4))
current_y = 5
# Timestamp
timestamp_str = time.ctime(self.current_note.timestamp)
self.stdscr.addstr(current_y, 2, f"Timestamp: {timestamp_str}")
current_y += 1
# Tags
if self.current_note.tags:
tags_str = " ".join([f"#{tag}" for tag in self.current_note.tags])
self.stdscr.addstr(current_y, 2, "Tags: ", curses.A_BOLD)
self.stdscr.addstr(current_y, 8, tags_str, curses.color_pair(3))
current_y += 1
current_y += 1
# Content with tag highlighting
self.stdscr.addstr(current_y, 2, "Content:", curses.A_BOLD)
current_y += 1
# Display content with highlighted tags
content_lines = self.current_note.content.split('\n')
max_content_lines = self.content_h - (current_y - 2) - 6 # Reserve space for hash/sig
for line in content_lines[:max_content_lines]:
if current_y >= self.height - 6:
break
# Highlight tags in the content
display_line = self._safe_truncate(line, self.width - 6)
x_pos = 4
# Simple tag highlighting - split by words and color tags
import re
parts = re.split(r'(#\w+)', display_line)
for part in parts:
if part.startswith('#'):
try:
self.stdscr.addstr(current_y, x_pos, part, curses.color_pair(3))
except curses.error:
pass
x_pos += len(part)
else:
if x_pos < self.width - 2:
try:
self.stdscr.addstr(current_y, x_pos, part[:self.width - x_pos - 2])
except curses.error:
pass
x_pos += len(part)
current_y += 1
current_y += 1
# Hash
if self.current_note.content_hash:
hash_display = self._safe_truncate(self.current_note.content_hash, self.width - 12)
self.stdscr.addstr(current_y, 2, f"Hash: {hash_display}", curses.A_DIM)
current_y += 1
# Signature
if self.current_note.signature:
self.stdscr.addstr(current_y, 2, "Signature: [GPG signed]", curses.color_pair(2))
current_y += 1
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3))
def draw_help(self):
"""Draw the help screen with keyboard shortcuts and features"""
self.stdscr.addstr(2, 2, "trace - Help & Keyboard Shortcuts", curses.A_BOLD)
self.stdscr.addstr(3, 2, "" * (self.width - 4))
# Build help content as a list of lines
help_lines = []
# General Navigation
help_lines.append(("GENERAL NAVIGATION", curses.A_BOLD | curses.color_pair(2)))
help_lines.append((" Arrow Keys Navigate lists and menus", curses.A_NORMAL))
help_lines.append((" Enter Select item / Open", curses.A_NORMAL))
help_lines.append((" b Go back to previous view", curses.A_NORMAL))
help_lines.append((" q Quit application", curses.A_NORMAL))
help_lines.append((" ? or h Show this help screen", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL))
# Case List View
help_lines.append(("CASE LIST VIEW", curses.A_BOLD | curses.color_pair(2)))
help_lines.append((" N Create new case", curses.A_NORMAL))
help_lines.append((" n Add note to active context", curses.A_NORMAL))
help_lines.append((" a Set selected case as active", curses.A_NORMAL))
help_lines.append((" d Delete selected case (with confirmation)", curses.A_NORMAL))
help_lines.append((" / Filter cases by case number or name", curses.A_NORMAL))
help_lines.append((" s Open settings menu", curses.A_NORMAL))
help_lines.append((" Enter Open case details", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL))
# Case Detail View
help_lines.append(("CASE DETAIL VIEW", curses.A_BOLD | curses.color_pair(2)))
help_lines.append((" N Create new evidence item", curses.A_NORMAL))
help_lines.append((" n Add note to case", curses.A_NORMAL))
help_lines.append((" t View tags across case and all evidence", curses.A_NORMAL))
help_lines.append((" i View IOCs across case and all evidence", curses.A_NORMAL))
help_lines.append((" v View all case notes", curses.A_NORMAL))
help_lines.append((" a Set case (or selected evidence) as active", curses.A_NORMAL))
help_lines.append((" d Delete selected evidence item", curses.A_NORMAL))
help_lines.append((" / Filter evidence by name or description", curses.A_NORMAL))
help_lines.append((" Enter Open evidence details", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL))
# Evidence Detail View
help_lines.append(("EVIDENCE DETAIL VIEW", curses.A_BOLD | curses.color_pair(2)))
help_lines.append((" n Add note to evidence", curses.A_NORMAL))
help_lines.append((" t View tags for this evidence", curses.A_NORMAL))
help_lines.append((" i View IOCs for this evidence", curses.A_NORMAL))
help_lines.append((" v View all evidence notes", curses.A_NORMAL))
help_lines.append((" a Set evidence as active context", curses.A_NORMAL))
help_lines.append((" d Delete selected note", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL))
# Tags View
help_lines.append(("TAGS VIEW", curses.A_BOLD | curses.color_pair(2)))
help_lines.append((" Enter View all notes with selected tag", curses.A_NORMAL))
help_lines.append((" b Return to previous view", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL))
# IOCs View
help_lines.append(("IOCs VIEW", curses.A_BOLD | curses.color_pair(2)))
help_lines.append((" Enter View all notes containing selected IOC", curses.A_NORMAL))
help_lines.append((" e Export IOCs to text file", curses.A_NORMAL))
help_lines.append((" b Return to previous view", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL))
# Note Editor
help_lines.append(("NOTE EDITOR", curses.A_BOLD | curses.color_pair(2)))
help_lines.append((" Arrow Keys Navigate within text", curses.A_NORMAL))
help_lines.append((" Enter New line (multi-line notes supported)", curses.A_NORMAL))
help_lines.append((" Backspace Delete character", curses.A_NORMAL))
help_lines.append((" Ctrl+G Submit note", curses.A_NORMAL))
help_lines.append((" Esc Cancel note creation", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL))
# Features
help_lines.append(("FEATURES", curses.A_BOLD | curses.color_pair(2)))
help_lines.append((" Active Context Set with 'a' key - enables CLI quick notes", curses.A_NORMAL))
help_lines.append((" Run: trace \"your note text\"", curses.A_DIM))
help_lines.append((" Tags Use #hashtag in notes for auto-tagging", curses.A_NORMAL))
help_lines.append((" IOCs Auto-extracts IPs, domains, URLs, hashes, emails", curses.A_NORMAL))
help_lines.append((" Integrity All notes SHA256 hashed + optional GPG signing", curses.A_NORMAL))
help_lines.append((" GPG Settings Press 's' to toggle signing & select GPG key", curses.A_NORMAL))
help_lines.append((" Source Hash Store evidence file hashes for chain of custody", curses.A_NORMAL))
help_lines.append((" Export Run: trace --export --output report.md", curses.A_DIM))
help_lines.append(("", curses.A_NORMAL))
# Data Location
help_lines.append(("DATA STORAGE", curses.A_BOLD | curses.color_pair(2)))
help_lines.append((" All data: ~/.trace/data.json", curses.A_NORMAL))
help_lines.append((" Active context: ~/.trace/state", curses.A_NORMAL))
help_lines.append((" Settings: ~/.trace/settings.json", curses.A_NORMAL))
help_lines.append((" IOC exports: ~/.trace/exports/", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL))
# Demo Case Note
help_lines.append(("GETTING STARTED", curses.A_BOLD | curses.color_pair(2)))
help_lines.append((" Demo Case A sample case (DEMO-2024-001) showcases all features", curses.A_NORMAL))
help_lines.append((" Explore evidence, notes, tags, and IOCs", curses.A_DIM))
help_lines.append((" Delete it when ready: select and press 'd'", curses.A_DIM))
# Calculate scrolling
total_lines = len(help_lines)
list_h = self.content_h - 2 # Account for header
# Update scroll based on selection (treat as line navigation)
if self.selected_index < self.scroll_offset:
self.scroll_offset = self.selected_index
elif self.selected_index >= self.scroll_offset + list_h:
self.scroll_offset = self.selected_index - list_h + 1
# Ensure scroll_offset is within bounds
max_scroll = max(0, total_lines - list_h)
if self.scroll_offset > max_scroll:
self.scroll_offset = max_scroll
if self.scroll_offset < 0:
self.scroll_offset = 0
# Draw visible help lines
y_offset = 5
for i in range(list_h):
line_idx = self.scroll_offset + i
if line_idx >= total_lines:
break
text, attr = help_lines[line_idx]
y = y_offset + i
if y >= self.height - 3:
break
# Truncate if needed
display_text = self._safe_truncate(text, self.width - 4)
try:
self.stdscr.addstr(y, 2, display_text, attr)
except curses.error:
pass # Ignore display errors at screen boundaries
# Show scroll indicator if content doesn't fit
if total_lines > list_h:
scroll_info = f"[{self.scroll_offset + 1}-{min(self.scroll_offset + list_h, total_lines)} of {total_lines}]"
try:
self.stdscr.addstr(self.height - 3, self.width - len(scroll_info) - 2, scroll_info, curses.color_pair(3))
except curses.error:
pass
self.stdscr.addstr(self.height - 3, 2, "[Arrow Keys] Scroll [b/q/?] Close", curses.color_pair(3))
def handle_input(self, key):
if self.filter_mode:
return self.handle_filter_input(key)
# Help screen - accessible from anywhere
if key == ord('?') or key == ord('h'):
# Save previous view to return to it
if not hasattr(self, 'previous_view'):
self.previous_view = self.current_view
else:
# If already in help, don't update previous_view
if self.current_view != "help":
self.previous_view = self.current_view
self.current_view = "help"
self.selected_index = 0
self.scroll_offset = 0
return True
if key == ord('q'):
# If in help view, just close help instead of quitting
if self.current_view == "help":
self.current_view = getattr(self, 'previous_view', 'case_list')
self.selected_index = 0
self.scroll_offset = 0
return True
return False
# Filter toggle
if key == ord('/'):
# Filter works on list views: case_list and case_detail (evidence list)
if self.current_view in ["case_list", "case_detail"]:
self.filter_mode = True
return True
# Navigation
if key == curses.KEY_UP:
if self.current_view == "help":
# Scrolling help content
if self.selected_index > 0:
self.selected_index -= 1
return True
if self.selected_index > 0:
self.selected_index -= 1
elif key == curses.KEY_DOWN:
# Calculate max_idx based on current filtered view
max_idx = 0
if self.current_view == "case_list":
filtered = self._get_filtered_list(self.cases, "case_number", "name")
max_idx = len(filtered) - 1
elif self.current_view == "case_detail" and self.active_case:
# Total items = case notes + evidence
case_notes = self.active_case.notes
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
max_idx = len(case_notes) + len(filtered) - 1
elif self.current_view == "evidence_detail" and self.active_evidence:
# Navigate through notes in evidence detail view
max_idx = len(self.active_evidence.notes) - 1
elif self.current_view == "tags_list":
max_idx = len(self.current_tags) - 1
elif self.current_view == "tag_notes_list":
max_idx = len(self.tag_notes) - 1
elif self.current_view == "ioc_list":
max_idx = len(self.current_iocs) - 1
elif self.current_view == "ioc_notes_list":
max_idx = len(self.ioc_notes) - 1
elif self.current_view == "help":
# Scrolling help content - just increment scroll_offset directly
# Help view uses scroll_offset for scrolling, not selected_index
list_h = self.content_h - 2
# We'll increment selected_index but it's just used for scroll calculation
max_idx = 100 # Arbitrary large number for help content
self.selected_index += 1
return True
if max_idx < 0: max_idx = 0 # Handle empty list
if self.selected_index < max_idx:
self.selected_index += 1
# Enter / Select
elif key == curses.KEY_ENTER or key in [10, 13]:
if self.current_view == "case_list":
filtered = self._get_filtered_list(self.cases, "case_number", "name")
if filtered:
self.active_case = filtered[self.selected_index]
self.current_view = "case_detail"
self.selected_index = 0
self.scroll_offset = 0
self.filter_query = "" # Reset filter on view change
elif self.current_view == "case_detail":
if self.active_case:
case_notes = self.active_case.notes
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
# Check if selecting a note or evidence
if self.selected_index < len(case_notes):
# Selected a note - show note detail view
self.current_note = case_notes[self.selected_index]
self.previous_view = "case_detail"
self.current_view = "note_detail"
self.filter_query = ""
elif filtered and self.selected_index - len(case_notes) < len(filtered):
# Selected evidence - navigate to evidence detail
evidence_idx = self.selected_index - len(case_notes)
self.active_evidence = filtered[evidence_idx]
self.current_view = "evidence_detail"
self.selected_index = 0
self.filter_query = ""
elif self.current_view == "tags_list":
# Enter tag -> show notes with that tag
if self.current_tags and self.selected_index < len(self.current_tags):
tag, _ = self.current_tags[self.selected_index]
self.current_tag = tag
# Get all notes (case + evidence if in case view, or just evidence if in evidence view)
all_notes = self._get_context_notes()
self.tag_notes = self._get_notes_with_tag(all_notes, tag)
# Sort by timestamp descending
self.tag_notes.sort(key=lambda n: n.timestamp, reverse=True)
self.current_view = "tag_notes_list"
self.selected_index = 0
self.scroll_offset = 0
elif self.current_view == "tag_notes_list":
# Enter note -> show expanded view
if self.tag_notes and self.selected_index < len(self.tag_notes):
self.current_note = self.tag_notes[self.selected_index]
self.previous_view = "tag_notes_list"
self.current_view = "note_detail"
self.selected_index = 0
self.scroll_offset = 0
elif self.current_view == "ioc_list":
# Enter IOC -> show notes with that IOC
if self.current_iocs and self.selected_index < len(self.current_iocs):
ioc, _, _ = self.current_iocs[self.selected_index]
self.current_ioc = ioc
# Get all notes from current context
all_notes = self._get_context_notes()
self.ioc_notes = self._get_notes_with_ioc(all_notes, ioc)
# Sort by timestamp descending
self.ioc_notes.sort(key=lambda n: n.timestamp, reverse=True)
self.current_view = "ioc_notes_list"
self.selected_index = 0
self.scroll_offset = 0
elif self.current_view == "ioc_notes_list":
# Enter note -> show expanded view
if self.ioc_notes and self.selected_index < len(self.ioc_notes):
self.current_note = self.ioc_notes[self.selected_index]
self.previous_view = "ioc_notes_list"
self.current_view = "note_detail"
self.selected_index = 0
self.scroll_offset = 0
# Back
elif key == ord('b'):
if self.current_view == "help":
# Return to previous view
self.current_view = getattr(self, 'previous_view', 'case_list')
self.selected_index = 0
self.scroll_offset = 0
elif self.current_view == "note_detail":
# Return to the view we came from
self.current_view = getattr(self, 'previous_view', 'case_detail')
self.current_note = None
self.selected_index = 0
self.scroll_offset = 0
elif self.current_view == "tag_notes_list":
self.current_view = "tags_list"
self.tag_notes = []
self.current_tag = None
self.selected_index = 0
self.scroll_offset = 0
elif self.current_view == "ioc_notes_list":
self.current_view = "ioc_list"
self.ioc_notes = []
self.current_ioc = None
self.selected_index = 0
self.scroll_offset = 0
elif self.current_view == "ioc_list":
# Go back to the view we came from (case_detail or evidence_detail)
if self.active_evidence:
self.current_view = "evidence_detail"
elif self.active_case:
self.current_view = "case_detail"
self.current_iocs = []
self.selected_index = 0
self.scroll_offset = 0
elif self.current_view == "tags_list":
# Go back to the view we came from (case_detail or evidence_detail)
if self.active_evidence:
self.current_view = "evidence_detail"
elif self.active_case:
self.current_view = "case_detail"
self.current_tags = []
self.selected_index = 0
self.scroll_offset = 0
elif self.current_view == "evidence_detail":
self.current_view = "case_detail"
self.active_evidence = None
self.selected_index = 0
self.scroll_offset = 0
self.filter_query = ""
elif self.current_view == "case_detail":
self.current_view = "case_list"
self.active_case = None
self.selected_index = 0
self.scroll_offset = 0
self.filter_query = ""
# Export IOCs
elif key == ord('e'):
if self.current_view in ["ioc_list", "ioc_notes_list"]:
self.export_iocs()
# Set Active
elif key == ord('a'):
self._handle_set_active()
# Settings
elif key == ord('s') and self.current_view == "case_list":
self.dialog_settings()
# Actions
elif key == ord('N') and self.current_view == "case_list":
self.dialog_new_case()
elif key == ord('N') and self.current_view == "case_detail":
self.dialog_new_evidence()
elif key == ord('n'):
self.dialog_add_note()
elif key == ord('t'):
self.handle_open_tags()
elif key == ord('i'):
self.handle_open_iocs()
elif key == ord('v'):
if self.current_view == "case_detail":
self.view_case_notes()
elif self.current_view == "evidence_detail":
self.view_evidence_notes()
# Delete
elif key == ord('d'):
self.handle_delete()
return True
def handle_filter_input(self, key):
if key == 27: # ESC
self.filter_mode = False
self.filter_query = ""
self.selected_index = 0
self.scroll_offset = 0
return True
elif key == curses.KEY_ENTER or key in [10, 13]:
self.filter_mode = False
self.selected_index = 0
self.scroll_offset = 0
return True
elif key == curses.KEY_BACKSPACE or key == 127:
if len(self.filter_query) > 0:
self.filter_query = self.filter_query[:-1]
self.selected_index = 0
elif 32 <= key <= 126:
self.filter_query += chr(key)
self.selected_index = 0
return True
def _handle_set_active(self):
if self.current_view == "case_list":
filtered = self._get_filtered_list(self.cases, "case_number", "name")
if filtered:
case = filtered[self.selected_index]
self.state_manager.set_active(case_id=case.case_id, evidence_id=None)
self.global_active_case_id = case.case_id
self.global_active_evidence_id = None
self.show_message(f"Active Case: {case.case_number}")
elif self.current_view == "case_detail" and self.active_case:
case_notes = self.active_case.notes
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
# Only allow setting active for evidence, not notes
if self.selected_index < len(case_notes):
# Selected a note - set case as active (not evidence)
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=None)
self.global_active_case_id = self.active_case.case_id
self.global_active_evidence_id = None
self.show_message(f"Active: Case {self.active_case.case_number}")
elif filtered and self.selected_index - len(case_notes) < len(filtered):
# Selected evidence - set it as active
evidence_idx = self.selected_index - len(case_notes)
ev = filtered[evidence_idx]
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=ev.evidence_id)
self.global_active_case_id = self.active_case.case_id
self.global_active_evidence_id = ev.evidence_id
self.show_message(f"Active: {ev.name}")
else:
# Nothing selected - set case as active
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=None)
self.global_active_case_id = self.active_case.case_id
self.global_active_evidence_id = None
self.show_message(f"Active Case: {self.active_case.case_number}")
elif self.current_view == "evidence_detail" and self.active_evidence and self.active_case:
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=self.active_evidence.evidence_id)
self.global_active_case_id = self.active_case.case_id
self.global_active_evidence_id = self.active_evidence.evidence_id
self.show_message(f"Active: {self.active_evidence.name}")
def _input_dialog(self, title, prompt=""):
"""
Single-line text input dialog with full Unicode/UTF-8 support.
Handles umlauts and other special characters properly.
"""
curses.noecho()
curses.curs_set(1)
# Calculate dimensions - taller to show prompt and footer
h = 6 if prompt else 4
w = min(60, self.width - 4)
y = self.height // 2 - 3
x = (self.width - w) // 2
win = curses.newwin(h, w, y, x)
win.box()
win.attron(curses.A_BOLD | curses.color_pair(1))
win.addstr(0, 2, f" {title} ", curses.A_BOLD)
win.attroff(curses.A_BOLD | curses.color_pair(1))
# Show prompt if provided
input_y = 1
if prompt:
win.addstr(1, 2, prompt, curses.color_pair(3))
input_y = 3
# Footer with cancel instruction
win.addstr(h - 2, 2, "[ESC] Cancel", curses.A_DIM)
win.refresh()
# Text input state
text = ""
cursor_pos = 0 # Cursor position in characters (not bytes)
max_width = w - 6 # Leave space for borders and padding
def redraw_input():
"""Redraw the input line"""
# Clear the input area
win.addstr(input_y, 2, " " * (w - 4))
# Display text (handle scrolling if too long)
display_text = text
display_offset = 0
# If text is too long, scroll to show cursor position
if len(display_text) > max_width:
# Calculate offset to keep cursor visible
if cursor_pos > max_width - 5:
display_offset = cursor_pos - max_width + 5
display_text = display_text[display_offset:display_offset + max_width]
try:
win.addstr(input_y, 2, display_text)
except curses.error:
pass # Ignore if text is too wide
# Position cursor
cursor_x = min(cursor_pos - display_offset + 2, w - 3)
try:
win.move(input_y, cursor_x)
except curses.error:
pass
win.refresh()
# Main input loop
while True:
redraw_input()
try:
ch = win.getch()
except KeyboardInterrupt:
curses.curs_set(0)
del win
return None
# Handle special keys
if ch == 27: # ESC
curses.curs_set(0)
del win
return None
elif ch == 10 or ch == 13: # Enter
curses.curs_set(0)
del win
return text.strip() if text.strip() else None
elif ch == curses.KEY_BACKSPACE or ch == 127 or ch == 8:
# Backspace
if cursor_pos > 0:
text = text[:cursor_pos-1] + text[cursor_pos:]
cursor_pos -= 1
elif ch == curses.KEY_DC: # Delete key
if cursor_pos < len(text):
text = text[:cursor_pos] + text[cursor_pos+1:]
elif ch == curses.KEY_LEFT:
if cursor_pos > 0:
cursor_pos -= 1
elif ch == curses.KEY_RIGHT:
if cursor_pos < len(text):
cursor_pos += 1
elif ch == curses.KEY_HOME or ch == 1: # Home or Ctrl+A
cursor_pos = 0
elif ch == curses.KEY_END or ch == 5: # End or Ctrl+E
cursor_pos = len(text)
elif 32 <= ch <= 126:
# Regular ASCII printable characters
text = text[:cursor_pos] + chr(ch) + text[cursor_pos:]
cursor_pos += 1
elif ch > 127:
# UTF-8 multi-byte character (umlauts, etc.)
# curses returns the first byte, we need to read the rest
try:
# Try to decode as UTF-8
# For multibyte UTF-8, we need to collect all bytes
bytes_collected = [ch]
# Determine how many bytes we need based on the first byte
if ch >= 0xF0: # 4-byte character
num_bytes = 4
elif ch >= 0xE0: # 3-byte character
num_bytes = 3
elif ch >= 0xC0: # 2-byte character
num_bytes = 2
else:
num_bytes = 1
# Read remaining bytes
for _ in range(num_bytes - 1):
next_ch = win.getch()
bytes_collected.append(next_ch)
# Convert to character
char_bytes = bytes([b & 0xFF for b in bytes_collected])
char = char_bytes.decode('utf-8')
# Insert character
text = text[:cursor_pos] + char + text[cursor_pos:]
cursor_pos += 1
except (UnicodeDecodeError, ValueError):
# If decode fails, ignore the character
pass
def _multiline_input_dialog(self, title, prompt="", recent_notes=None, max_lines=10):
"""
Multi-line text input dialog with optional recent notes preview.
Args:
title: Dialog title
prompt: Prompt text to show above input
recent_notes: List of recent Note objects to display inline
max_lines: Maximum lines for input area
Returns:
String content or None if cancelled
"""
curses.curs_set(1)
curses.noecho()
# Calculate dimensions
# Need space for: title, prompt, recent notes, input area, footer
recent_note_lines = 0
if recent_notes:
# Show up to 3 recent notes, 2 lines each
recent_note_lines = min(len(recent_notes), 3) * 2 + 1 # +1 for header
prompt_lines = prompt.count('\n') + 1 if prompt else 0
# Dialog height: title(1) + prompt + recent notes + input area + footer(2) + borders
dialog_h = min(self.height - 4, 4 + prompt_lines + recent_note_lines + max_lines + 2)
dialog_w = min(70, self.width - 4)
dialog_y = max(2, (self.height - dialog_h) // 2)
dialog_x = (self.width - dialog_w) // 2
win = curses.newwin(dialog_h, dialog_w, dialog_y, dialog_x)
win.box()
# Title
win.attron(curses.A_BOLD | curses.color_pair(1))
title_text = f" {title} "
win.addstr(0, 2, title_text[:dialog_w-4])
win.attroff(curses.A_BOLD | curses.color_pair(1))
current_y = 1
# Show prompt if provided
if prompt:
for line in prompt.split('\n'):
if current_y < dialog_h - 2:
win.addstr(current_y, 2, line[:dialog_w-4], curses.color_pair(3))
current_y += 1
# Show recent notes inline (non-blocking!)
if recent_notes and current_y < dialog_h - max_lines - 2:
win.addstr(current_y, 2, "Recent notes:", curses.A_DIM)
current_y += 1
for note in recent_notes[-3:]: # Last 3 notes
if current_y >= dialog_h - max_lines - 2:
break
timestamp_str = time.ctime(note.timestamp)[-13:-5] # Just time HH:MM:SS
# Replace newlines with spaces to keep on one line
note_content_single_line = note.content.replace('\n', ' ').replace('\r', ' ')
# Truncate safely for Unicode
max_preview_len = dialog_w - 18 # Account for timestamp and padding
note_preview = self._safe_truncate(note_content_single_line, max_preview_len)
try:
win.addstr(current_y, 2, f"[{timestamp_str}] {note_preview}", curses.color_pair(2))
except curses.error:
# Silently handle curses errors (e.g., string too wide)
pass
current_y += 1
# Calculate input area position and size
input_start_y = current_y + 1 if current_y > 1 else current_y
input_height = min(max_lines, dialog_h - input_start_y - 3) # -3 for footer + border
input_width = dialog_w - 4
# Footer
footer_y = dialog_h - 2
win.addstr(footer_y, 2, "[Ctrl+G] Submit [ESC] Cancel", curses.A_DIM)
win.refresh()
# Text storage
lines = [""]
cursor_line = 0
cursor_col = 0
scroll_offset = 0
def redraw_input():
"""Redraw the input area with current text"""
for i in range(input_height):
line_idx = scroll_offset + i
y = input_start_y + i
# Clear the line
win.addstr(y, 2, " " * input_width)
if line_idx < len(lines):
# Show line content
display_text = lines[line_idx][:input_width]
win.addstr(y, 2, display_text)
# Position cursor
display_cursor_line = cursor_line - scroll_offset
if 0 <= display_cursor_line < input_height:
cursor_y = input_start_y + display_cursor_line
cursor_x = min(cursor_col + 2, input_width + 1)
try:
win.move(cursor_y, cursor_x)
except curses.error:
pass
win.refresh()
# Main input loop
while True:
redraw_input()
try:
ch = win.getch()
except KeyboardInterrupt:
curses.curs_set(0)
del win
return None
# Handle special keys
if ch == 27: # ESC
curses.curs_set(0)
del win
return None
elif ch == 7: # Ctrl+G - Submit
curses.curs_set(0)
del win
result = '\n'.join(lines).strip()
return result if result else None
elif ch == curses.KEY_UP:
if cursor_line > 0:
cursor_line -= 1
cursor_col = min(cursor_col, len(lines[cursor_line]))
# Adjust scroll if needed
if cursor_line < scroll_offset:
scroll_offset = cursor_line
elif ch == curses.KEY_DOWN:
if cursor_line < len(lines) - 1:
cursor_line += 1
cursor_col = min(cursor_col, len(lines[cursor_line]))
# Adjust scroll if needed
if cursor_line >= scroll_offset + input_height:
scroll_offset = cursor_line - input_height + 1
elif ch == curses.KEY_LEFT:
if cursor_col > 0:
cursor_col -= 1
elif cursor_line > 0:
# Move to end of previous line
cursor_line -= 1
cursor_col = len(lines[cursor_line])
if cursor_line < scroll_offset:
scroll_offset = cursor_line
elif ch == curses.KEY_RIGHT:
if cursor_col < len(lines[cursor_line]):
cursor_col += 1
elif cursor_line < len(lines) - 1:
# Move to start of next line
cursor_line += 1
cursor_col = 0
if cursor_line >= scroll_offset + input_height:
scroll_offset = cursor_line - input_height + 1
elif ch == curses.KEY_HOME or ch == 1: # Home or Ctrl+A
cursor_col = 0
elif ch == curses.KEY_END or ch == 5: # End or Ctrl+E
cursor_col = len(lines[cursor_line])
elif ch == curses.KEY_BACKSPACE or ch == 127 or ch == 8:
if cursor_col > 0:
# Delete character before cursor
line = lines[cursor_line]
lines[cursor_line] = line[:cursor_col-1] + line[cursor_col:]
cursor_col -= 1
elif cursor_line > 0:
# Merge with previous line
cursor_col = len(lines[cursor_line - 1])
lines[cursor_line - 1] += lines[cursor_line]
del lines[cursor_line]
cursor_line -= 1
if cursor_line < scroll_offset:
scroll_offset = cursor_line
elif ch == curses.KEY_DC: # Delete key
line = lines[cursor_line]
if cursor_col < len(line):
lines[cursor_line] = line[:cursor_col] + line[cursor_col+1:]
elif cursor_line < len(lines) - 1:
# Merge with next line
lines[cursor_line] += lines[cursor_line + 1]
del lines[cursor_line + 1]
elif ch == 10 or ch == 13: # Enter - new line
# Split current line at cursor
line = lines[cursor_line]
lines[cursor_line] = line[:cursor_col]
lines.insert(cursor_line + 1, line[cursor_col:])
cursor_line += 1
cursor_col = 0
# Adjust scroll if needed
if cursor_line >= scroll_offset + input_height:
scroll_offset = cursor_line - input_height + 1
elif 32 <= ch <= 126: # Printable characters
# Insert character at cursor
line = lines[cursor_line]
lines[cursor_line] = line[:cursor_col] + chr(ch) + line[cursor_col:]
cursor_col += 1
def dialog_confirm(self, message):
curses.curs_set(0)
h = 5
w = len(message) + 10
y = self.height // 2 - 2
x = (self.width - w) // 2
win = curses.newwin(h, w, y, x)
win.box()
win.addstr(1, 2, message)
win.addstr(3, 2, " [y] Yes [n] No ")
win.refresh()
while True:
ch = win.getch()
if ch == ord('y') or ch == ord('Y'):
del win
return True
elif ch == ord('n') or ch == ord('N') or ch == 27:
del win
return False
def dialog_settings(self):
"""Settings menu with GPG signing toggle and key selection"""
from .crypto import Crypto
# Load current settings
settings = self.state_manager.get_settings()
pgp_enabled = settings.get("pgp_enabled", True)
current_key = settings.get("gpg_key_id", None)
# Menu state
selected_option = 0
options = ["GPG Signing", "Select GPG Key", "Save", "Cancel"]
curses.curs_set(0)
h = 12
w = 60
y = self.height // 2 - 6
x = (self.width - w) // 2
win = curses.newwin(h, w, y, x)
win.keypad(1) # Enable keypad mode for arrow keys
while True:
win.clear()
win.box()
win.addstr(0, 2, " Settings ", curses.A_BOLD)
# Display current settings
win.addstr(2, 2, "Current Configuration:", curses.A_UNDERLINE)
# GPG Signing status
status = "ENABLED" if pgp_enabled else "DISABLED"
color = curses.color_pair(2) if pgp_enabled else curses.color_pair(3)
win.addstr(4, 4, "GPG Signing: ")
win.addstr(4, 18, f"{status}", color)
# Current GPG Key
if current_key:
key_display = current_key[:16] + "..." if len(current_key) > 16 else current_key
win.addstr(5, 4, f"GPG Key: {key_display}")
else:
win.addstr(5, 4, "GPG Key: [Default]", curses.A_DIM)
win.addstr(7, 2, "Options:", curses.A_UNDERLINE)
# Menu options
for i, option in enumerate(options):
y_pos = 8 + i
if i == selected_option:
win.addstr(y_pos, 4, f"> {option}", curses.color_pair(1))
else:
win.addstr(y_pos, 4, f" {option}")
# Footer
win.addstr(h - 2, 2, "[Arrow Keys] Navigate [Enter] Select [Esc] Cancel", curses.A_DIM)
win.refresh()
ch = win.getch()
if ch == curses.KEY_UP:
if selected_option > 0:
selected_option -= 1
elif ch == curses.KEY_DOWN:
if selected_option < len(options) - 1:
selected_option += 1
elif ch == 10 or ch == 13: # Enter
if selected_option == 0: # Toggle GPG Signing
pgp_enabled = not pgp_enabled
elif selected_option == 1: # Select GPG Key
# Open key selection dialog
selected_key = self._dialog_select_gpg_key(current_key)
if selected_key is not None:
current_key = selected_key
elif selected_option == 2: # Save
self.state_manager.set_setting("pgp_enabled", pgp_enabled)
self.state_manager.set_setting("gpg_key_id", current_key)
self.show_message("Settings saved.")
break
elif selected_option == 3: # Cancel
break
elif ch == 27: # Esc
break
del win
def _dialog_select_gpg_key(self, current_key):
"""Dialog to select a GPG key from available keys"""
from .crypto import Crypto
# Get available keys
available_keys = Crypto.list_gpg_keys()
if not available_keys:
# Show error message
self._show_error_dialog("No GPG Keys Found",
"No GPG secret keys found on this system.\n"
"Please generate a key using 'gpg --gen-key'.")
return None
# Add option for default key
key_options = [("default", "[Use GPG Default Key]")] + available_keys
selected_idx = 0
# Find currently selected key in list
if current_key:
for i, (key_id, _) in enumerate(key_options):
if key_id == current_key:
selected_idx = i
break
curses.curs_set(0)
h = min(len(key_options) + 6, self.height - 4)
w = min(70, self.width - 4)
y = (self.height - h) // 2
x = (self.width - w) // 2
win = curses.newwin(h, w, y, x)
win.keypad(1) # Enable keypad mode for arrow keys
scroll_offset = 0
while True:
win.clear()
win.box()
win.addstr(0, 2, " Select GPG Key ", curses.A_BOLD)
list_h = h - 5
# Update scroll
if selected_idx < scroll_offset:
scroll_offset = selected_idx
elif selected_idx >= scroll_offset + list_h:
scroll_offset = selected_idx - list_h + 1
# Display keys
for i in range(list_h):
idx = scroll_offset + i
if idx >= len(key_options):
break
key_id, user_id = key_options[idx]
y_pos = 2 + i
# Truncate if needed
display_text = f"{key_id[:12]}... - {user_id[:40]}" if len(user_id) > 40 else f"{key_id[:12]}... - {user_id}"
if key_id == "default":
display_text = user_id
display_text = self._safe_truncate(display_text, w - 6)
if idx == selected_idx:
win.addstr(y_pos, 2, f"> {display_text}", curses.color_pair(1))
else:
win.addstr(y_pos, 2, f" {display_text}")
# Footer
win.addstr(h - 2, 2, "[Arrow Keys] Navigate [Enter] Select [Esc] Cancel", curses.A_DIM)
win.refresh()
ch = win.getch()
if ch == curses.KEY_UP:
if selected_idx > 0:
selected_idx -= 1
elif ch == curses.KEY_DOWN:
if selected_idx < len(key_options) - 1:
selected_idx += 1
elif ch == 10 or ch == 13: # Enter
selected_key_id, _ = key_options[selected_idx]
del win
# Return None for default, otherwise return the key ID
return None if selected_key_id == "default" else selected_key_id
elif ch == 27: # Esc
del win
return None
del win
return None
def _show_error_dialog(self, title, message):
"""Show a simple error dialog"""
curses.curs_set(0)
# Calculate size based on message
lines = message.split('\n')
h = len(lines) + 5
w = min(max(len(line) for line in lines) + 6, self.width - 4)
y = (self.height - h) // 2
x = (self.width - w) // 2
win = curses.newwin(h, w, y, x)
win.box()
win.addstr(0, 2, f" {title} ", curses.A_BOLD | curses.color_pair(4))
for i, line in enumerate(lines):
win.addstr(2 + i, 2, self._safe_truncate(line, w - 4))
win.addstr(h - 2, 2, "Press any key to continue...", curses.A_DIM)
win.refresh()
win.getch()
del win
def dialog_new_case(self):
case_num = self._input_dialog("New Case - Step 1/3", "Enter Case ID (required):")
if case_num is None:
self.show_message("Case creation cancelled.")
return
if not case_num:
self.show_message("Case ID is required.")
return
name = self._input_dialog("New Case - Step 2/3", "Enter descriptive name (optional):")
if name is None:
self.show_message("Case creation cancelled.")
return
investigator = self._input_dialog("New Case - Step 3/3", "Enter investigator name (optional):")
if investigator is None:
self.show_message("Case creation cancelled.")
return
case = Case(case_number=case_num, name=name or "", investigator=investigator or "")
self.storage.add_case(case)
# After add_case, the case is already in self.storage.cases, no need to reload
# Reload would create new object instances from disk, breaking any existing references
self.show_message(f"Case {case_num} created.")
def dialog_new_evidence(self):
if not self.active_case: return
name = self._input_dialog("New Evidence - Step 1/3", "Enter evidence name (required):")
if name is None:
self.show_message("Evidence creation cancelled.")
return
if not name:
self.show_message("Evidence name is required.")
return
desc = self._input_dialog("New Evidence - Step 2/3", "Enter description (optional):")
if desc is None:
self.show_message("Evidence creation cancelled.")
return
source_hash = self._input_dialog("New Evidence - Step 3/3", "Enter source hash (optional, e.g. SHA256):")
if source_hash is None:
self.show_message("Evidence creation cancelled.")
return
ev = Evidence(name=name, description=desc or "")
if source_hash:
ev.metadata["source_hash"] = source_hash
self.active_case.evidence.append(ev)
self.storage.save_data()
self.show_message(f"Evidence '{name}' added.")
def dialog_add_note(self):
# Determine context for the note
context_title = "Add Note"
context_prompt = "Enter note content:"
recent_notes = []
target_case = None
target_evidence = None
if self.current_view == "evidence_detail" and self.active_evidence:
context_title = f"Add Note → Evidence: {self.active_evidence.name}"
context_prompt = f"Case: {self.active_case.case_number if self.active_case else '?'}\nEvidence: {self.active_evidence.name}\n\nNote will be added to this evidence."
recent_notes = self.active_evidence.notes[-5:] if len(self.active_evidence.notes) > 0 else []
target_evidence = self.active_evidence
elif self.current_view == "case_detail" and self.active_case:
context_title = f"Add Note → Case: {self.active_case.case_number}"
context_prompt = f"Case: {self.active_case.case_number}\n{self.active_case.name if self.active_case.name else ''}\n\nNote will be added to case notes."
recent_notes = self.active_case.notes[-5:] if len(self.active_case.notes) > 0 else []
target_case = self.active_case
elif self.current_view == "case_list":
# If in case list, try to use global active context
if self.global_active_case_id:
active_case = self.storage.get_case(self.global_active_case_id)
if active_case:
if self.global_active_evidence_id:
# Find evidence
for ev in active_case.evidence:
if ev.evidence_id == self.global_active_evidence_id:
context_title = f"Add Note → Evidence: {ev.name}"
context_prompt = f"Case: {active_case.case_number}\nEvidence: {ev.name}\n\nNote will be added to this evidence."
recent_notes = ev.notes[-5:] if len(ev.notes) > 0 else []
target_case = active_case
target_evidence = ev
break
else:
context_title = f"Add Note → Case: {active_case.case_number}"
context_prompt = f"Case: {active_case.case_number}\n\nNote will be added to case notes."
recent_notes = active_case.notes[-5:] if len(active_case.notes) > 0 else []
target_case = active_case
if not target_case:
self.show_message("No active case/evidence. Set active context first.")
return
# Use multi-line input dialog with inline recent notes (non-blocking!)
content = self._multiline_input_dialog(context_title, context_prompt, recent_notes=recent_notes, max_lines=10)
if content is None:
self.show_message("Note creation cancelled.")
return
if not content:
self.show_message("Note content cannot be empty.")
return
# Create and save the note
from .crypto import Crypto
# Check settings
settings = self.state_manager.get_settings()
pgp_enabled = settings.get("pgp_enabled", True)
gpg_key_id = settings.get("gpg_key_id", None)
note = Note(content=content)
note.calculate_hash()
note.extract_tags() # Extract hashtags from content
note.extract_iocs() # Extract IOCs from content
signed = False
if pgp_enabled:
sig = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id)
if sig:
note.signature = sig
signed = True
else:
self.show_message("Note Saved. GPG Signing Failed!")
# Add note to the appropriate target
if target_evidence:
target_evidence.notes.append(note)
elif target_case:
target_case.notes.append(note)
elif self.current_view == "evidence_detail" and self.active_evidence:
self.active_evidence.notes.append(note)
elif self.current_view == "case_detail" and self.active_case:
self.active_case.notes.append(note)
self.storage.save_data()
if not (pgp_enabled and not signed):
self.show_message("Note added successfully.")
def handle_delete(self):
if self.current_view == "case_list":
filtered = self._get_filtered_list(self.cases, "case_number", "name")
if filtered:
case_to_del = filtered[self.selected_index]
if self.dialog_confirm(f"Delete Case {case_to_del.case_number}?"):
self.storage.delete_case(case_to_del.case_id)
# Check active state
if self.global_active_case_id == case_to_del.case_id:
self.state_manager.set_active(None, None)
self.global_active_case_id = None
self.global_active_evidence_id = None
# Refresh
self.cases = self.storage.cases
self.selected_index = 0
self.scroll_offset = 0
self.show_message(f"Case {case_to_del.case_number} deleted.")
elif self.current_view == "case_detail" and self.active_case:
# Determine if we're deleting a note or evidence based on selected index
case_notes = self.active_case.notes
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
# Check if selecting a note (indices 0 to len(notes)-1)
if self.selected_index < len(case_notes):
# Delete case note
note_to_del = case_notes[self.selected_index]
preview = note_to_del.content[:50] + "..." if len(note_to_del.content) > 50 else note_to_del.content
if self.dialog_confirm(f"Delete note: '{preview}'?"):
self.active_case.notes.remove(note_to_del)
self.storage.save_data()
self.selected_index = 0
self.scroll_offset = 0
self.show_message("Note deleted.")
elif filtered and self.selected_index - len(case_notes) < len(filtered):
# Delete evidence (adjust index by subtracting case notes count)
evidence_idx = self.selected_index - len(case_notes)
ev_to_del = filtered[evidence_idx]
if self.dialog_confirm(f"Delete Evidence {ev_to_del.name}?"):
self.storage.delete_evidence(self.active_case.case_id, ev_to_del.evidence_id)
# Check active state
if self.global_active_evidence_id == ev_to_del.evidence_id:
# Fallback to case active
self.state_manager.set_active(self.active_case.case_id, None)
self.global_active_evidence_id = None
# Refresh
updated_case = self.storage.get_case(self.active_case.case_id)
if updated_case:
self.active_case = updated_case
self.selected_index = 0
self.scroll_offset = 0
self.show_message(f"Evidence '{ev_to_del.name}' deleted.")
elif self.current_view == "evidence_detail" and self.active_evidence:
# Delete individual notes from evidence
if not self.active_evidence.notes:
self.show_message("No notes to delete.")
return
# Calculate which note to delete based on display (showing last N notes)
notes = self.active_evidence.notes
list_h = self.content_h - 5 # Adjust for header
display_notes = notes[-list_h:] if len(notes) > list_h else notes
if display_notes:
# User selection is in context of displayed notes
# We need to delete from the full list
if self.selected_index < len(display_notes):
note_to_del = display_notes[self.selected_index]
# Show preview of note content in confirmation
preview = note_to_del.content[:50] + "..." if len(note_to_del.content) > 50 else note_to_del.content
if self.dialog_confirm(f"Delete note: '{preview}'?"):
self.active_evidence.notes.remove(note_to_del)
self.storage.save_data()
self.selected_index = 0
self.scroll_offset = 0
self.show_message("Note deleted.")
def view_case_notes(self):
if not self.active_case: return
h = int(self.height * 0.8)
w = int(self.width * 0.8)
y = int(self.height * 0.1)
x = int(self.width * 0.1)
while True:
win = curses.newwin(h, w, y, x)
win.box()
win.addstr(1, 2, f"Notes: {self.active_case.case_number}", curses.A_BOLD)
notes = self.active_case.notes
max_lines = h - 4
# Scroll last notes
display_notes = notes[-max_lines:] if len(notes) > max_lines else notes
for i, note in enumerate(display_notes):
# Replace newlines with spaces for single-line display
note_content = note.content.replace('\n', ' ').replace('\r', ' ')
display_str = f"- [{time.ctime(note.timestamp)}] {note_content}"
# Truncate safely for Unicode
display_str = self._safe_truncate(display_str, w - 4)
win.addstr(3 + i, 2, display_str)
win.addstr(h-2, 2, "[n] Add Note [b/q/Esc] Close", curses.color_pair(3))
win.refresh()
key = win.getch()
del win
# Handle key presses
if key == ord('n') or key == ord('N'):
# Save current view and switch to case_detail temporarily for context
saved_view = self.current_view
self.current_view = "case_detail"
self.dialog_add_note()
self.current_view = saved_view
# Continue loop to refresh with new note
elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc
break
else:
# Any other key also closes (backwards compatibility)
break
def view_evidence_notes(self):
if not self.active_evidence: return
h = int(self.height * 0.8)
w = int(self.width * 0.8)
y = int(self.height * 0.1)
x = int(self.width * 0.1)
while True:
win = curses.newwin(h, w, y, x)
win.box()
win.addstr(1, 2, f"Notes: {self.active_evidence.name}", curses.A_BOLD)
notes = self.active_evidence.notes
max_lines = h - 4
# Scroll last notes
display_notes = notes[-max_lines:] if len(notes) > max_lines else notes
for i, note in enumerate(display_notes):
# Replace newlines with spaces for single-line display
note_content = note.content.replace('\n', ' ').replace('\r', ' ')
display_str = f"- [{time.ctime(note.timestamp)}] {note_content}"
# Truncate safely for Unicode
display_str = self._safe_truncate(display_str, w - 4)
win.addstr(3 + i, 2, display_str)
win.addstr(h-2, 2, "[n] Add Note [b/q/Esc] Close", curses.color_pair(3))
win.refresh()
key = win.getch()
del win
# Handle key presses
if key == ord('n') or key == ord('N'):
# Save current view and switch to evidence_detail temporarily for context
saved_view = self.current_view
self.current_view = "evidence_detail"
self.dialog_add_note()
self.current_view = saved_view
# Continue loop to refresh with new note
elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc
break
else:
# Any other key also closes (backwards compatibility)
break
def export_iocs(self):
"""Export IOCs from current context to a text file"""
import os
from pathlib import Path
if not self.current_iocs:
self.show_message("No IOCs to export.")
return
# Determine context for filename
if self.active_evidence:
context_name = f"{self.active_case.case_number}_{self.active_evidence.name}" if self.active_case else self.active_evidence.name
elif self.active_case:
context_name = self.active_case.case_number
else:
context_name = "unknown"
# Clean filename
context_name = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in context_name)
# Create exports directory if it doesn't exist
export_dir = Path.home() / ".trace" / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
# Generate filename with timestamp
import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"iocs_{context_name}_{timestamp}.txt"
filepath = export_dir / filename
# Build export content
lines = []
lines.append(f"# IOC Export - {context_name}")
lines.append(f"# Generated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("")
if self.active_evidence:
# Evidence context - only evidence IOCs
lines.append(f"## Evidence: {self.active_evidence.name}")
lines.append("")
for ioc, count, ioc_type in self.current_iocs:
lines.append(f"{ioc}\t[{ioc_type}]\t({count} occurrences)")
elif self.active_case:
# Case context - show case IOCs + evidence IOCs with separators
# Get case notes IOCs
case_iocs = self._get_all_iocs_with_counts(self.active_case.notes)
if case_iocs:
lines.append("## Case Notes")
lines.append("")
for ioc, count, ioc_type in case_iocs:
lines.append(f"{ioc}\t[{ioc_type}]\t({count} occurrences)")
lines.append("")
# Get IOCs from each evidence
for ev in self.active_case.evidence:
ev_iocs = self._get_all_iocs_with_counts(ev.notes)
if ev_iocs:
lines.append(f"## Evidence: {ev.name}")
lines.append("")
for ioc, count, ioc_type in ev_iocs:
lines.append(f"{ioc}\t[{ioc_type}]\t({count} occurrences)")
lines.append("")
# Write to file
try:
with open(filepath, 'w') as f:
f.write('\n'.join(lines))
self.show_message(f"IOCs exported to: {filepath}")
except Exception as e:
self.show_message(f"Export failed: {str(e)}")
def run_tui(open_active=False):
"""
Run the TUI application.
Args:
open_active: If True, navigate directly to the active case/evidence view
"""
def tui_wrapper(stdscr):
tui = TUI(stdscr)
# If requested, navigate to active case/evidence
if open_active:
if tui.global_active_case_id:
# Find the active case
case = tui.storage.get_case(tui.global_active_case_id)
if case:
tui.active_case = case
if tui.global_active_evidence_id:
# Navigate to evidence detail
for ev in case.evidence:
if ev.evidence_id == tui.global_active_evidence_id:
tui.active_evidence = ev
tui.current_view = "evidence_detail"
break
else:
# Evidence not found, just go to case detail
tui.current_view = "case_detail"
else:
# Navigate to case detail
tui.current_view = "case_detail"
tui.run()
curses.wrapper(tui_wrapper)