6 Commits

Author SHA1 Message Date
overcuriousity
e1886edee1 bug fixes 2025-12-12 10:26:27 +01:00
overcuriousity
aa0f67f1fc visual improvements, readme file update 2025-12-12 00:00:35 +01:00
overcuriousity
dc8bd777ef bug fixes 2025-12-11 23:40:16 +01:00
overcuriousity
89e7b20694 bug fixes 2025-12-11 23:25:06 +01:00
overcuriousity
ba1fff36f2 rename binaries 2025-12-11 23:09:04 +01:00
overcuriousity
e37597c315 bug fixes 2025-12-11 23:07:19 +01:00
6 changed files with 622 additions and 132 deletions

View File

@@ -27,14 +27,14 @@ jobs:
- name: Build Linux binary - name: Build Linux binary
run: | run: |
pyinstaller --onefile --name trace-linux main.py pyinstaller --onefile --name trace main.py
- name: Upload Linux binary to release - name: Upload Linux binary to release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
files: ./dist/trace-linux files: ./dist/trace
build-windows: build-windows:
runs-on: windows-latest runs-on: windows-latest
@@ -55,11 +55,11 @@ jobs:
- name: Build Windows executable - name: Build Windows executable
run: | run: |
pyinstaller --onefile --name trace-windows main.py pyinstaller --onefile --name trace main.py
- name: Upload Windows executable to release - name: Upload Windows executable to release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
files: ./dist/trace-windows.exe files: ./dist/trace.exe

View File

@@ -122,7 +122,13 @@ Run `trace` without arguments to open the interface.
* IOC counts are displayed in red in case and evidence views. * IOC counts are displayed in red in case and evidence views.
* `a`: **Set Active**. Sets the currently selected Case or Evidence as the global "Active" context. * `a`: **Set Active**. Sets the currently selected Case or Evidence as the global "Active" context.
* `d`: Delete the selected Case or Evidence (with confirmation). * `d`: Delete the selected Case or Evidence (with confirmation).
* `v`: View all notes for the current Case (in Case Detail view). * `v`: **View All Notes**. View all notes for the current Case or Evidence in a scrollable full-screen view.
* **IOC Highlighting**: All IOCs in notes are automatically highlighted in red for immediate visibility.
* **Tag Highlighting**: Hashtags are highlighted in cyan.
* Press `Enter` on any note in case/evidence detail view to jump directly to that note in the full view.
* The selected note will be centered and highlighted.
* Navigate with arrow keys, Page Up/Down, Home/End.
* Press `n` to add a new note without leaving the view.
* `/`: Filter list (type to search, `Esc` or `Enter` to exit filter mode). * `/`: Filter list (type to search, `Esc` or `Enter` to exit filter mode).
* `s`: Settings menu (in Case List view). * `s`: Settings menu (in Case List view).
* `Esc`: Cancel during input dialogs. * `Esc`: Cancel during input dialogs.

View File

@@ -39,14 +39,17 @@ def quick_add_note(content: str):
note.extract_iocs() # Extract IOCs from content note.extract_iocs() # Extract IOCs from content
# Try signing if enabled # Try signing if enabled
signature = None
if settings.get("pgp_enabled", True): if settings.get("pgp_enabled", True):
gpg_key_id = settings.get("gpg_key_id", None) gpg_key_id = settings.get("gpg_key_id", None)
if gpg_key_id: if gpg_key_id:
signature = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id) signature = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id)
if signature: if signature:
note.signature = signature note.signature = signature
else:
print("Warning: GPG signature failed (GPG not found or no key). Note saved without signature.")
else: else:
print("Warning: GPG signature failed (GPG not found or no key). Note saved without signature.") print("Warning: No GPG key ID configured. Note saved without signature.")
# Attach to evidence or case # Attach to evidence or case
if target_evidence: if target_evidence:

View File

@@ -96,6 +96,112 @@ class Note:
data = f"{self.timestamp}:{self.content}".encode('utf-8') data = f"{self.timestamp}:{self.content}".encode('utf-8')
self.content_hash = hashlib.sha256(data).hexdigest() self.content_hash = hashlib.sha256(data).hexdigest()
@staticmethod
def extract_iocs_from_text(text):
"""Extract IOCs from text and return as list of (ioc, type) tuples"""
iocs = []
seen = set()
# IPv4 addresses
ipv4_pattern = r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'
for match in re.findall(ipv4_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'ipv4'))
# IPv6 addresses (simplified)
ipv6_pattern = r'\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b'
for match in re.findall(ipv6_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'ipv6'))
# URLs (check before domains to avoid double-matching)
url_pattern = r'https?://[^\s]+'
for match in re.findall(url_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'url'))
# Domain names (basic pattern)
domain_pattern = r'\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b'
for match in re.findall(domain_pattern, text):
# Filter out common false positives and already seen URLs
if match not in seen and not match.startswith('example.'):
seen.add(match)
iocs.append((match, 'domain'))
# SHA256 hashes (64 hex chars) - check before SHA1 and MD5
sha256_pattern = r'\b[a-fA-F0-9]{64}\b'
for match in re.findall(sha256_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'sha256'))
# SHA1 hashes (40 hex chars) - check before MD5
sha1_pattern = r'\b[a-fA-F0-9]{40}\b'
for match in re.findall(sha1_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'sha1'))
# MD5 hashes (32 hex chars)
md5_pattern = r'\b[a-fA-F0-9]{32}\b'
for match in re.findall(md5_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'md5'))
# Email addresses
email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
for match in re.findall(email_pattern, text):
if match not in seen:
seen.add(match)
iocs.append((match, 'email'))
return iocs
@staticmethod
def extract_iocs_with_positions(text):
"""Extract IOCs with their positions for highlighting. Returns list of (text, start, end, type) tuples"""
import re
highlights = []
# IPv4 addresses
for match in re.finditer(r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'ipv4'))
# IPv6 addresses
for match in re.finditer(r'\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'ipv6'))
# URLs (check before domains)
for match in re.finditer(r'https?://[^\s]+', text):
highlights.append((match.group(), match.start(), match.end(), 'url'))
# Domain names
for match in re.finditer(r'\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b', text):
if not match.group().startswith('example.'):
highlights.append((match.group(), match.start(), match.end(), 'domain'))
# SHA256 hashes
for match in re.finditer(r'\b[a-fA-F0-9]{64}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'sha256'))
# SHA1 hashes
for match in re.finditer(r'\b[a-fA-F0-9]{40}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'sha1'))
# MD5 hashes
for match in re.finditer(r'\b[a-fA-F0-9]{32}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'md5'))
# Email addresses
for match in re.finditer(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', text):
highlights.append((match.group(), match.start(), match.end(), 'email'))
return highlights
def to_dict(self): def to_dict(self):
return { return {
"note_id": self.note_id, "note_id": self.note_id,

View File

@@ -23,7 +23,6 @@ class Storage:
def _create_demo_case(self): def _create_demo_case(self):
"""Create a demo case with evidence showcasing all features""" """Create a demo case with evidence showcasing all features"""
# Create demo case
demo_case = Case( demo_case = Case(
case_number="DEMO-2024-001", case_number="DEMO-2024-001",
name="Sample Investigation", name="Sample Investigation",

View File

@@ -57,6 +57,10 @@ class TUI:
curses.init_pair(7, curses.COLOR_BLUE, curses.COLOR_BLACK) curses.init_pair(7, curses.COLOR_BLUE, curses.COLOR_BLACK)
# Tags (magenta) # Tags (magenta)
curses.init_pair(8, curses.COLOR_MAGENTA, curses.COLOR_BLACK) curses.init_pair(8, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
# IOCs on selected background (red on cyan)
curses.init_pair(9, curses.COLOR_RED, curses.COLOR_CYAN)
# Tags on selected background (yellow on cyan)
curses.init_pair(10, curses.COLOR_YELLOW, curses.COLOR_CYAN)
self.height, self.width = stdscr.getmaxyx() self.height, self.width = stdscr.getmaxyx()
@@ -257,6 +261,102 @@ class TUI:
return ellipsis[:max_width] return ellipsis[:max_width]
def _display_line_with_highlights(self, y, x_start, line, is_selected=False, win=None):
"""
Display a line with intelligent highlighting.
- IOCs are highlighted with color_pair(4) (red)
- Tags are highlighted with color_pair(3) (yellow)
- Selection background is color_pair(1) (cyan) for non-IOC text
- IOC highlighting takes priority over selection
"""
import re
from .models import Note
# Use provided window or default to main screen
screen = win if win is not None else self.stdscr
# Extract IOCs and tags
highlights = []
# Get IOCs with positions
for text, start, end, ioc_type in Note.extract_iocs_with_positions(line):
highlights.append((text, start, end, 'ioc'))
# Get tags
for match in re.finditer(r'#\w+', line):
highlights.append((match.group(), match.start(), match.end(), 'tag'))
# Sort by position and remove overlaps (IOCs take priority over tags)
highlights.sort(key=lambda x: x[1])
deduplicated = []
last_end = -1
for text, start, end, htype in highlights:
if start >= last_end:
deduplicated.append((text, start, end, htype))
last_end = end
highlights = deduplicated
if not highlights:
# No highlights - use selection color if selected
if is_selected:
screen.attron(curses.color_pair(1))
screen.addstr(y, x_start, line)
screen.attroff(curses.color_pair(1))
else:
screen.addstr(y, x_start, line)
return
# Display with intelligent highlighting
x_pos = x_start
last_pos = 0
for text, start, end, htype in highlights:
# Add text before this highlight
if start > last_pos:
text_before = line[last_pos:start]
if is_selected:
screen.attron(curses.color_pair(1))
screen.addstr(y, x_pos, text_before)
screen.attroff(curses.color_pair(1))
else:
screen.addstr(y, x_pos, text_before)
x_pos += len(text_before)
# Add highlighted text
if htype == 'ioc':
# IOC highlighting: red on cyan if selected, red on black otherwise
if is_selected:
screen.attron(curses.color_pair(9) | curses.A_BOLD)
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(9) | curses.A_BOLD)
else:
screen.attron(curses.color_pair(4) | curses.A_BOLD)
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(4) | curses.A_BOLD)
else: # tag
# Tag highlighting: yellow on cyan if selected, yellow on black otherwise
if is_selected:
screen.attron(curses.color_pair(10))
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(10))
else:
screen.attron(curses.color_pair(3))
screen.addstr(y, x_pos, text)
screen.attroff(curses.color_pair(3))
x_pos += len(text)
last_pos = end
# Add remaining text
if last_pos < len(line):
text_after = line[last_pos:]
if is_selected:
screen.attron(curses.color_pair(1))
screen.addstr(y, x_pos, text_after)
screen.attroff(curses.color_pair(1))
else:
screen.addstr(y, x_pos, text_after)
def draw_header(self): def draw_header(self):
# Modern header with icon and better styling # Modern header with icon and better styling
title = "◆ trace" title = "◆ trace"
@@ -490,40 +590,77 @@ class TUI:
self.stdscr.attroff(note_color) self.stdscr.attroff(note_color)
y_pos += 1 y_pos += 1
# Evidence section header # Split screen between evidence and case notes
y_pos += 1 # Allocate space: half for evidence, half for case notes (if both exist)
self.stdscr.attron(curses.color_pair(5) | curses.A_BOLD) available_space = self.content_h - 5
self.stdscr.addstr(y_pos, 2, "▪ Evidence") case_notes = self.active_case.notes
self.stdscr.attroff(curses.color_pair(5) | curses.A_BOLD)
evidence_list = self._get_filtered_list(self.active_case.evidence, "name", "description") evidence_list = self._get_filtered_list(self.active_case.evidence, "name", "description")
# Show count # Determine context: are we selecting evidence or notes?
self.stdscr.attron(curses.color_pair(6) | curses.A_DIM) # Evidence items are indices 0 to len(evidence)-1
self.stdscr.addstr(y_pos, 14, f"({len(evidence_list)} items)") # Case notes are indices len(evidence) to len(evidence)+len(notes)-1
self.stdscr.attroff(curses.color_pair(6) | curses.A_DIM) total_items = len(evidence_list) + len(case_notes)
# Determine what's selected
selecting_evidence = self.selected_index < len(evidence_list)
# Evidence section header
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 y_pos += 1
if not evidence_list: if not evidence_list:
self.stdscr.attron(curses.color_pair(3)) # Check if we have space to display the message
self.stdscr.addstr(y_pos + 1, 4, "┌─ No evidence items") if y_pos + 2 < self.height - 2:
self.stdscr.addstr(y_pos + 2, 4, "└─ Press 'N' to add evidence") self.stdscr.attron(curses.color_pair(3))
self.stdscr.attroff(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: else:
# Scrolling for evidence list # Scrolling for evidence list
# List starts at y=7 # Calculate remaining space
list_h = self.content_h - 5 # 7 is header offset remaining_space = self.content_h - (y_pos - 2)
if list_h < 1: list_h = 1 list_h = max(1, remaining_space)
self._update_scroll(len(evidence_list)) self._update_scroll(total_items)
for i in range(list_h): # Calculate space for evidence
idx = self.scroll_offset + i evidence_space = min(len(evidence_list), available_space // 2) if case_notes else available_space
if idx >= len(evidence_list): break
ev = evidence_list[idx] self._update_scroll(total_items)
y = y_pos + 2 + i
# Calculate which evidence items to display
# If selecting evidence, scroll just enough to keep it visible
# If selecting a case note, show evidence from the beginning
if selecting_evidence:
# Keep selection visible: scroll up if needed, scroll down if needed
if self.selected_index < 0:
evidence_scroll_offset = 0
elif self.selected_index >= evidence_space:
# Scroll down only as much as needed to show the selected item at the bottom
evidence_scroll_offset = self.selected_index - evidence_space + 1
else:
evidence_scroll_offset = 0
else:
evidence_scroll_offset = 0
for i in range(evidence_space):
evidence_idx = evidence_scroll_offset + i
if evidence_idx < 0 or evidence_idx >= len(evidence_list):
continue
ev = evidence_list[evidence_idx]
y = y_pos + i
if y >= self.height - 3: # Don't overflow into status bar
break
note_count = len(ev.notes) note_count = len(ev.notes)
@@ -563,7 +700,8 @@ class TUI:
# Truncate safely # Truncate safely
base_display = self._safe_truncate(display_str, self.width - 6) base_display = self._safe_truncate(display_str, self.width - 6)
if idx == self.selected_index: # Check if this evidence item is selected
if evidence_idx == self.selected_index:
# Highlighted selection # Highlighted selection
self.stdscr.attron(curses.color_pair(1)) self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(y, 4, base_display) self.stdscr.addstr(y, 4, base_display)
@@ -596,6 +734,52 @@ class TUI:
else: else:
self.stdscr.addstr(y, 4, base_display) self.stdscr.addstr(y, 4, base_display)
y_pos += evidence_space
# Case Notes section
if case_notes:
y_pos += 2
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 remaining space for case notes
remaining_space = self.content_h - (y_pos - 2)
notes_space = max(1, remaining_space)
# Calculate which notes to display
if selecting_evidence:
notes_scroll_offset = 0
else:
notes_scroll_offset = max(0, (self.selected_index - len(evidence_list)) - notes_space // 2)
for i in range(notes_space):
note_idx = notes_scroll_offset + i
if note_idx >= len(case_notes):
break
note = case_notes[note_idx]
y = y_pos + 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)
# Display with smart highlighting (IOCs take priority over selection)
item_idx = len(evidence_list) + note_idx
is_selected = (item_idx == self.selected_index)
self._display_line_with_highlights(y, 4, display_str, is_selected)
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)) 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): def draw_evidence_detail(self):
@@ -652,13 +836,9 @@ class TUI:
# Truncate safely for Unicode # Truncate safely for Unicode
display_str = self._safe_truncate(display_str, self.width - 6) display_str = self._safe_truncate(display_str, self.width - 6)
# Highlight selected note # Display with smart highlighting (IOCs take priority over selection)
if idx == self.selected_index: is_selected = (idx == self.selected_index)
self.stdscr.attron(curses.color_pair(1)) self._display_line_with_highlights(start_y + i, 4, display_str, is_selected)
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)) 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))
@@ -833,11 +1013,11 @@ class TUI:
current_y += 1 current_y += 1
# Content with tag highlighting # Content with tag and IOC highlighting
self.stdscr.addstr(current_y, 2, "Content:", curses.A_BOLD) self.stdscr.addstr(current_y, 2, "Content:", curses.A_BOLD)
current_y += 1 current_y += 1
# Display content with highlighted tags # Display content with highlighted tags and IOCs
content_lines = self.current_note.content.split('\n') content_lines = self.current_note.content.split('\n')
max_content_lines = self.content_h - (current_y - 2) - 6 # Reserve space for hash/sig max_content_lines = self.content_h - (current_y - 2) - 6 # Reserve space for hash/sig
@@ -845,27 +1025,14 @@ class TUI:
if current_y >= self.height - 6: if current_y >= self.height - 6:
break break
# Highlight tags in the content # Highlight both tags and IOCs in the content
display_line = self._safe_truncate(line, self.width - 6) display_line = self._safe_truncate(line, self.width - 6)
x_pos = 4
# Simple tag highlighting - split by words and color tags # Display with highlighting (no selection in detail view)
import re try:
parts = re.split(r'(#\w+)', display_line) self._display_line_with_highlights(current_y, 4, display_line, is_selected=False)
for part in parts: except curses.error:
if part.startswith('#'): pass
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
@@ -918,11 +1085,11 @@ class TUI:
help_lines.append((" n Add note to case", 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((" 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((" 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((" v View all case notes with IOC highlighting", curses.A_NORMAL))
help_lines.append((" a Set case (or selected evidence) as active", 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((" d Delete selected evidence item or note", curses.A_NORMAL))
help_lines.append((" / Filter evidence by name or description", 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((" Enter Open evidence details or jump to note", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL)) help_lines.append(("", curses.A_NORMAL))
# Evidence Detail View # Evidence Detail View
@@ -930,9 +1097,10 @@ class TUI:
help_lines.append((" n Add note to evidence", curses.A_NORMAL)) 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((" t View tags for this evidence", curses.A_NORMAL))
help_lines.append((" i View IOCs 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((" v View all evidence notes with IOC highlighting", curses.A_NORMAL))
help_lines.append((" a Set evidence as active context", 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((" d Delete selected note", curses.A_NORMAL))
help_lines.append((" Enter Jump to selected note in full view", curses.A_NORMAL))
help_lines.append(("", curses.A_NORMAL)) help_lines.append(("", curses.A_NORMAL))
# Tags View # Tags View
@@ -962,7 +1130,11 @@ class TUI:
help_lines.append((" Active Context Set with 'a' key - enables CLI quick notes", curses.A_NORMAL)) 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((" 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((" Tags Use #hashtag in notes for auto-tagging", curses.A_NORMAL))
help_lines.append((" Highlighted in cyan throughout the interface", curses.A_DIM))
help_lines.append((" IOCs Auto-extracts IPs, domains, URLs, hashes, emails", curses.A_NORMAL)) help_lines.append((" IOCs Auto-extracts IPs, domains, URLs, hashes, emails", curses.A_NORMAL))
help_lines.append((" Highlighted in red in full note views", curses.A_DIM))
help_lines.append((" Note Navigation Press Enter on any note to view with highlighting", curses.A_NORMAL))
help_lines.append((" Selected note auto-centered and highlighted", curses.A_DIM))
help_lines.append((" Integrity All notes SHA256 hashed + optional GPG signing", 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((" 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((" Source Hash Store evidence file hashes for chain of custody", curses.A_NORMAL))
@@ -1082,8 +1254,10 @@ class TUI:
filtered = self._get_filtered_list(self.cases, "case_number", "name") filtered = self._get_filtered_list(self.cases, "case_number", "name")
max_idx = len(filtered) - 1 max_idx = len(filtered) - 1
elif self.current_view == "case_detail" and self.active_case: elif self.current_view == "case_detail" and self.active_case:
# Total items = evidence + case notes
case_notes = self.active_case.notes
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description") filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
max_idx = len(filtered) - 1 max_idx = len(filtered) + len(case_notes) - 1
elif self.current_view == "evidence_detail" and self.active_evidence: elif self.current_view == "evidence_detail" and self.active_evidence:
# Navigate through notes in evidence detail view # Navigate through notes in evidence detail view
max_idx = len(self.active_evidence.notes) - 1 max_idx = len(self.active_evidence.notes) - 1
@@ -1117,15 +1291,42 @@ class TUI:
self.current_view = "case_detail" self.current_view = "case_detail"
self.selected_index = 0 self.selected_index = 0
self.scroll_offset = 0 self.scroll_offset = 0
self.filter_query = "" # Reset filter on view change self.filter_query = ""
elif self.current_view == "evidence_detail" and self.active_evidence:
# Check if a note is selected
notes = self.active_evidence.notes
list_h = self.content_h - 5
display_notes = notes[-list_h:] if len(notes) > list_h else notes
if display_notes and self.selected_index < len(display_notes):
# Calculate the actual note index in the full list
note_offset = len(notes) - len(display_notes)
actual_note_index = note_offset + self.selected_index
# Open notes view and jump to selected note
self._highlight_note_idx = actual_note_index
self.view_evidence_notes(highlight_note_index=actual_note_index)
delattr(self, '_highlight_note_idx') # Reset filter on view change
elif self.current_view == "case_detail": elif self.current_view == "case_detail":
if self.active_case: if self.active_case:
case_notes = self.active_case.notes
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description") filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
if filtered:
# Check if selecting evidence or note
# Evidence items come first (indices 0 to len(filtered)-1)
# Case notes come second (indices len(filtered) to len(filtered)+len(case_notes)-1)
if self.selected_index < len(filtered):
# Selected evidence - navigate to evidence detail
self.active_evidence = filtered[self.selected_index] self.active_evidence = filtered[self.selected_index]
self.current_view = "evidence_detail" self.current_view = "evidence_detail"
self.selected_index = 0 self.selected_index = 0
self.filter_query = "" # Reset filter self.filter_query = ""
elif case_notes and self.selected_index - len(filtered) < len(case_notes):
# Selected a note - show note detail view
note_idx = self.selected_index - len(filtered)
self.current_note = case_notes[note_idx]
self.previous_view = "case_detail"
self.current_view = "note_detail"
self.filter_query = ""
elif self.current_view == "tags_list": elif self.current_view == "tags_list":
# Enter tag -> show notes with that tag # Enter tag -> show notes with that tag
if self.current_tags and self.selected_index < len(self.current_tags): if self.current_tags and self.selected_index < len(self.current_tags):
@@ -1143,6 +1344,7 @@ class TUI:
# Enter note -> show expanded view # Enter note -> show expanded view
if self.tag_notes and self.selected_index < len(self.tag_notes): if self.tag_notes and self.selected_index < len(self.tag_notes):
self.current_note = self.tag_notes[self.selected_index] self.current_note = self.tag_notes[self.selected_index]
self.previous_view = "tag_notes_list"
self.current_view = "note_detail" self.current_view = "note_detail"
self.selected_index = 0 self.selected_index = 0
self.scroll_offset = 0 self.scroll_offset = 0
@@ -1163,6 +1365,7 @@ class TUI:
# Enter note -> show expanded view # Enter note -> show expanded view
if self.ioc_notes and self.selected_index < len(self.ioc_notes): if self.ioc_notes and self.selected_index < len(self.ioc_notes):
self.current_note = self.ioc_notes[self.selected_index] self.current_note = self.ioc_notes[self.selected_index]
self.previous_view = "ioc_notes_list"
self.current_view = "note_detail" self.current_view = "note_detail"
self.selected_index = 0 self.selected_index = 0
self.scroll_offset = 0 self.scroll_offset = 0
@@ -1175,7 +1378,8 @@ class TUI:
self.selected_index = 0 self.selected_index = 0
self.scroll_offset = 0 self.scroll_offset = 0
elif self.current_view == "note_detail": elif self.current_view == "note_detail":
self.current_view = "tag_notes_list" # Return to the view we came from
self.current_view = getattr(self, 'previous_view', 'case_detail')
self.current_note = None self.current_note = None
self.selected_index = 0 self.selected_index = 0
self.scroll_offset = 0 self.scroll_offset = 0
@@ -1291,14 +1495,26 @@ class TUI:
self.show_message(f"Active Case: {case.case_number}") self.show_message(f"Active Case: {case.case_number}")
elif self.current_view == "case_detail" and self.active_case: 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") filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
if filtered:
# Evidence is displayed first (indices 0 to len(evidence)-1)
# Case notes are displayed second (indices len(evidence) to len(evidence)+len(notes)-1)
if self.selected_index < len(filtered):
# Selected evidence - set it as active
ev = filtered[self.selected_index] ev = filtered[self.selected_index]
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=ev.evidence_id) 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_case_id = self.active_case.case_id
self.global_active_evidence_id = ev.evidence_id self.global_active_evidence_id = ev.evidence_id
self.show_message(f"Active: {ev.name}") self.show_message(f"Active: {ev.name}")
elif case_notes and self.selected_index - len(filtered) < 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}")
else: else:
# Nothing selected - set case as active
self.state_manager.set_active(case_id=self.active_case.case_id, evidence_id=None) 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_case_id = self.active_case.case_id
self.global_active_evidence_id = None self.global_active_evidence_id = None
@@ -1559,7 +1775,7 @@ class TUI:
win.addstr(y, 2, " " * input_width) win.addstr(y, 2, " " * input_width)
if line_idx < len(lines): if line_idx < len(lines):
# Show line content # Show line content (truncated if too long)
display_text = lines[line_idx][:input_width] display_text = lines[line_idx][:input_width]
win.addstr(y, 2, display_text) win.addstr(y, 2, display_text)
@@ -1682,6 +1898,18 @@ class TUI:
lines[cursor_line] = line[:cursor_col] + chr(ch) + line[cursor_col:] lines[cursor_line] = line[:cursor_col] + chr(ch) + line[cursor_col:]
cursor_col += 1 cursor_col += 1
# Auto-wrap to next line if cursor exceeds visible width
if cursor_col >= input_width:
# Always ensure there's a next line to move to
if cursor_line >= len(lines) - 1:
# We're on the last line, add a new line
lines.append("")
cursor_line += 1
cursor_col = 0
# Adjust scroll if needed
if cursor_line >= scroll_offset + input_height:
scroll_offset = cursor_line - input_height + 1
def dialog_confirm(self, message): def dialog_confirm(self, message):
curses.curs_set(0) curses.curs_set(0)
h = 5 h = 5
@@ -1724,6 +1952,7 @@ class TUI:
x = (self.width - w) // 2 x = (self.width - w) // 2
win = curses.newwin(h, w, y, x) win = curses.newwin(h, w, y, x)
win.keypad(True) # Enable keypad mode for arrow keys
while True: while True:
win.clear() win.clear()
@@ -1820,6 +2049,7 @@ class TUI:
x = (self.width - w) // 2 x = (self.width - w) // 2
win = curses.newwin(h, w, y, x) win = curses.newwin(h, w, y, x)
win.keypad(True) # Enable keypad mode for arrow keys
scroll_offset = 0 scroll_offset = 0
while True: while True:
@@ -1913,14 +2143,10 @@ class TUI:
return return
name = self._input_dialog("New Case - Step 2/3", "Enter descriptive name (optional):") name = self._input_dialog("New Case - Step 2/3", "Enter descriptive name (optional):")
if name is None: # For optional fields, treat None as empty string (user pressed Enter on empty field)
self.show_message("Case creation cancelled.")
return
investigator = self._input_dialog("New Case - Step 3/3", "Enter investigator name (optional):") investigator = self._input_dialog("New Case - Step 3/3", "Enter investigator name (optional):")
if investigator is None: # For optional fields, treat None as empty string (user pressed Enter on empty field)
self.show_message("Case creation cancelled.")
return
case = Case(case_number=case_num, name=name or "", investigator=investigator or "") case = Case(case_number=case_num, name=name or "", investigator=investigator or "")
self.storage.add_case(case) self.storage.add_case(case)
@@ -1940,14 +2166,10 @@ class TUI:
return return
desc = self._input_dialog("New Evidence - Step 2/3", "Enter description (optional):") desc = self._input_dialog("New Evidence - Step 2/3", "Enter description (optional):")
if desc is None: # For optional fields, treat None as empty string (user pressed Enter on empty field)
self.show_message("Evidence creation cancelled.")
return
source_hash = self._input_dialog("New Evidence - Step 3/3", "Enter source hash (optional, e.g. SHA256):") source_hash = self._input_dialog("New Evidence - Step 3/3", "Enter source hash (optional, e.g. SHA256):")
if source_hash is None: # For optional fields, treat None as empty string (user pressed Enter on empty field)
self.show_message("Evidence creation cancelled.")
return
ev = Evidence(name=name, description=desc or "") ev = Evidence(name=name, description=desc or "")
if source_hash: if source_hash:
@@ -1966,12 +2188,12 @@ class TUI:
if self.current_view == "evidence_detail" and self.active_evidence: if self.current_view == "evidence_detail" and self.active_evidence:
context_title = f"Add Note → Evidence: {self.active_evidence.name}" 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." context_prompt = f"Case: {self.active_case.case_number if self.active_case else '?'}\nEvidence: {self.active_evidence.name}\n"
recent_notes = self.active_evidence.notes[-5:] if len(self.active_evidence.notes) > 0 else [] recent_notes = self.active_evidence.notes[-5:] if len(self.active_evidence.notes) > 0 else []
target_evidence = self.active_evidence target_evidence = self.active_evidence
elif self.current_view == "case_detail" and self.active_case: elif self.current_view == "case_detail" and self.active_case:
context_title = f"Add Note → Case: {self.active_case.case_number}" 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." context_prompt = f"Case: {self.active_case.case_number}\n{self.active_case.name if self.active_case.name else ''}\nNote will be added to case notes."
recent_notes = self.active_case.notes[-5:] if len(self.active_case.notes) > 0 else [] recent_notes = self.active_case.notes[-5:] if len(self.active_case.notes) > 0 else []
target_case = self.active_case target_case = self.active_case
elif self.current_view == "case_list": elif self.current_view == "case_list":
@@ -1984,14 +2206,14 @@ class TUI:
for ev in active_case.evidence: for ev in active_case.evidence:
if ev.evidence_id == self.global_active_evidence_id: if ev.evidence_id == self.global_active_evidence_id:
context_title = f"Add Note → Evidence: {ev.name}" 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." context_prompt = f"Case: {active_case.case_number}\nEvidence: {ev.name}\n"
recent_notes = ev.notes[-5:] if len(ev.notes) > 0 else [] recent_notes = ev.notes[-5:] if len(ev.notes) > 0 else []
target_case = active_case target_case = active_case
target_evidence = ev target_evidence = ev
break break
else: else:
context_title = f"Add Note → Case: {active_case.case_number}" 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." context_prompt = f"Case: {active_case.case_number}\nNote will be added to case notes."
recent_notes = active_case.notes[-5:] if len(active_case.notes) > 0 else [] recent_notes = active_case.notes[-5:] if len(active_case.notes) > 0 else []
target_case = active_case target_case = active_case
@@ -2024,7 +2246,7 @@ class TUI:
signed = False signed = False
if pgp_enabled: if pgp_enabled:
sig = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id) sig = Crypto.sign_content(f"Hash: {note.content_hash}\nContent: {note.content}", key_id=gpg_key_id or "")
if sig: if sig:
note.signature = sig note.signature = sig
signed = True signed = True
@@ -2064,9 +2286,25 @@ class TUI:
self.show_message(f"Case {case_to_del.case_number} deleted.") self.show_message(f"Case {case_to_del.case_number} deleted.")
elif self.current_view == "case_detail" and self.active_case: 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") filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
if filtered:
ev_to_del = filtered[self.selected_index] # 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}?"): 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) self.storage.delete_evidence(self.active_case.case_id, ev_to_del.evidence_id)
# Check active state # Check active state
@@ -2074,9 +2312,7 @@ class TUI:
# Fallback to case active # Fallback to case active
self.state_manager.set_active(self.active_case.case_id, None) self.state_manager.set_active(self.active_case.case_id, None)
self.global_active_evidence_id = None self.global_active_evidence_id = None
# Refresh (in-memory update was done by storage usually? No, storage reloads or we reload) # Refresh
# We need to reload active_case evidence list or trust storage.cases
# It's better to reload from storage to be safe
updated_case = self.storage.get_case(self.active_case.case_id) updated_case = self.storage.get_case(self.active_case.case_id)
if updated_case: if updated_case:
self.active_case = updated_case self.active_case = updated_case
@@ -2109,7 +2345,7 @@ class TUI:
self.scroll_offset = 0 self.scroll_offset = 0
self.show_message("Note deleted.") self.show_message("Note deleted.")
def view_case_notes(self): def view_case_notes(self, highlight_note_index=None):
if not self.active_case: return if not self.active_case: return
h = int(self.height * 0.8) h = int(self.height * 0.8)
@@ -2117,45 +2353,115 @@ class TUI:
y = int(self.height * 0.1) y = int(self.height * 0.1)
x = int(self.width * 0.1) x = int(self.width * 0.1)
scroll_offset = 0
highlight_idx = highlight_note_index # Store for persistent highlighting
while True: while True:
win = curses.newwin(h, w, y, x) win = curses.newwin(h, w, y, x)
win.keypad(True)
win.timeout(25) # 25ms timeout makes ESC responsive
win.box() win.box()
win.addstr(1, 2, f"Notes: {self.active_case.case_number}", curses.A_BOLD) win.addstr(1, 2, f"Notes: {self.active_case.case_number} ({len(self.active_case.notes)} total)", curses.A_BOLD)
notes = self.active_case.notes notes = self.active_case.notes
max_lines = h - 4 content_lines = []
note_line_ranges = [] # Track which lines belong to which note
# Scroll last notes # Build all content lines with separators between notes
display_notes = notes[-max_lines:] if len(notes) > max_lines else notes for note_idx, note in enumerate(notes):
start_line = len(content_lines)
timestamp_str = time.ctime(note.timestamp)
content_lines.append(f"[{timestamp_str}]")
# Split multi-line notes and wrap long lines
for line in note.content.split('\n'):
# Wrap long lines
while len(line) > w - 6:
content_lines.append(" " + line[:w-6])
line = line[w-6:]
content_lines.append(" " + line)
content_lines.append("") # Blank line between notes
end_line = len(content_lines) - 1
note_line_ranges.append((start_line, end_line, note_idx))
for i, note in enumerate(display_notes): max_display_lines = h - 5
# Replace newlines with spaces for single-line display total_lines = len(content_lines)
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)) # Jump to highlighted note on first render
if highlight_note_index is not None and note_line_ranges:
for start, end, idx in note_line_ranges:
if idx == highlight_note_index:
# Center the note in the view
note_middle = (start + end) // 2
scroll_offset = max(0, note_middle - max_display_lines // 2)
highlight_note_index = None # Only jump once
break
# Adjust scroll bounds
max_scroll = max(0, total_lines - max_display_lines)
scroll_offset = max(0, min(scroll_offset, max_scroll))
# Display lines with highlighting
for i in range(max_display_lines):
line_idx = scroll_offset + i
if line_idx >= total_lines:
break
display_line = self._safe_truncate(content_lines[line_idx], w - 4)
# Check if this line belongs to the highlighted note
is_highlighted = False
if highlight_idx is not None:
for start, end, idx in note_line_ranges:
if start <= line_idx <= end and idx == highlight_idx:
is_highlighted = True
break
try:
y_pos = 3 + i
# Use unified highlighting function
self._display_line_with_highlights(y_pos, 2, display_line, is_highlighted, win)
except curses.error:
pass
# Show scroll indicator
if total_lines > max_display_lines:
scroll_info = f"[{scroll_offset + 1}-{min(scroll_offset + max_display_lines, total_lines)}/{total_lines}]"
try:
win.addstr(2, w - len(scroll_info) - 3, scroll_info, curses.A_DIM)
except curses.error:
pass
win.addstr(h-2, 2, "[↑↓] Scroll [n] Add Note [b/q/Esc] Close", curses.color_pair(3))
win.refresh() win.refresh()
key = win.getch() key = win.getch()
if key == -1: # timeout, redraw
del win
continue
del win del win
# Handle key presses # Handle key presses
if key == ord('n') or key == ord('N'): if key == curses.KEY_UP:
scroll_offset = max(0, scroll_offset - 1)
elif key == curses.KEY_DOWN:
scroll_offset = min(max_scroll, scroll_offset + 1)
elif key == curses.KEY_PPAGE: # Page Up
scroll_offset = max(0, scroll_offset - max_display_lines)
elif key == curses.KEY_NPAGE: # Page Down
scroll_offset = min(max_scroll, scroll_offset + max_display_lines)
elif key == curses.KEY_HOME:
scroll_offset = 0
elif key == curses.KEY_END:
scroll_offset = max_scroll
elif key == ord('n') or key == ord('N'):
# Save current view and switch to case_detail temporarily for context # Save current view and switch to case_detail temporarily for context
saved_view = self.current_view saved_view = self.current_view
self.current_view = "case_detail" self.current_view = "case_detail"
self.dialog_add_note() self.dialog_add_note()
self.current_view = saved_view self.current_view = saved_view
# Continue loop to refresh with new note scroll_offset = max_scroll # Jump to bottom to show new note
elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc
break break
else:
# Any other key also closes (backwards compatibility)
break
def view_evidence_notes(self): def view_evidence_notes(self, highlight_note_index=None):
if not self.active_evidence: return if not self.active_evidence: return
h = int(self.height * 0.8) h = int(self.height * 0.8)
@@ -2163,43 +2469,113 @@ class TUI:
y = int(self.height * 0.1) y = int(self.height * 0.1)
x = int(self.width * 0.1) x = int(self.width * 0.1)
scroll_offset = 0
highlight_idx = highlight_note_index # Store for persistent highlighting
while True: while True:
win = curses.newwin(h, w, y, x) win = curses.newwin(h, w, y, x)
win.keypad(True)
win.timeout(25) # 25ms timeout makes ESC responsive
win.box() win.box()
win.addstr(1, 2, f"Notes: {self.active_evidence.name}", curses.A_BOLD) win.addstr(1, 2, f"Notes: {self.active_evidence.name} ({len(self.active_evidence.notes)} total)", curses.A_BOLD)
notes = self.active_evidence.notes notes = self.active_evidence.notes
max_lines = h - 4 content_lines = []
note_line_ranges = [] # Track which lines belong to which note
# Scroll last notes # Build all content lines with separators between notes
display_notes = notes[-max_lines:] if len(notes) > max_lines else notes for note_idx, note in enumerate(notes):
start_line = len(content_lines)
timestamp_str = time.ctime(note.timestamp)
content_lines.append(f"[{timestamp_str}]")
# Split multi-line notes and wrap long lines
for line in note.content.split('\n'):
# Wrap long lines
while len(line) > w - 6:
content_lines.append(" " + line[:w-6])
line = line[w-6:]
content_lines.append(" " + line)
content_lines.append("") # Blank line between notes
end_line = len(content_lines) - 1
note_line_ranges.append((start_line, end_line, note_idx))
for i, note in enumerate(display_notes): max_display_lines = h - 5
# Replace newlines with spaces for single-line display total_lines = len(content_lines)
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)) # Jump to highlighted note on first render
if highlight_note_index is not None and note_line_ranges:
for start, end, idx in note_line_ranges:
if idx == highlight_note_index:
# Center the note in the view
note_middle = (start + end) // 2
scroll_offset = max(0, note_middle - max_display_lines // 2)
highlight_note_index = None # Only jump once
break
# Adjust scroll bounds
max_scroll = max(0, total_lines - max_display_lines)
scroll_offset = max(0, min(scroll_offset, max_scroll))
# Display lines with highlighting
for i in range(max_display_lines):
line_idx = scroll_offset + i
if line_idx >= total_lines:
break
display_line = self._safe_truncate(content_lines[line_idx], w - 4)
# Check if this line belongs to the highlighted note
is_highlighted = False
if highlight_idx is not None:
for start, end, idx in note_line_ranges:
if start <= line_idx <= end and idx == highlight_idx:
is_highlighted = True
break
try:
y_pos = 3 + i
# Use unified highlighting function
self._display_line_with_highlights(y_pos, 2, display_line, is_highlighted, win)
except curses.error:
pass
# Show scroll indicator
if total_lines > max_display_lines:
scroll_info = f"[{scroll_offset + 1}-{min(scroll_offset + max_display_lines, total_lines)}/{total_lines}]"
try:
win.addstr(2, w - len(scroll_info) - 3, scroll_info, curses.A_DIM)
except curses.error:
pass
win.addstr(h-2, 2, "[↑↓] Scroll [n] Add Note [b/q/Esc] Close", curses.color_pair(3))
win.refresh() win.refresh()
key = win.getch() key = win.getch()
if key == -1: # timeout, redraw
del win
continue
del win del win
# Handle key presses # Handle key presses
if key == ord('n') or key == ord('N'): if key == curses.KEY_UP:
scroll_offset = max(0, scroll_offset - 1)
elif key == curses.KEY_DOWN:
scroll_offset = min(max_scroll, scroll_offset + 1)
elif key == curses.KEY_PPAGE: # Page Up
scroll_offset = max(0, scroll_offset - max_display_lines)
elif key == curses.KEY_NPAGE: # Page Down
scroll_offset = min(max_scroll, scroll_offset + max_display_lines)
elif key == curses.KEY_HOME:
scroll_offset = 0
elif key == curses.KEY_END:
scroll_offset = max_scroll
elif key == ord('n') or key == ord('N'):
# Save current view and switch to evidence_detail temporarily for context # Save current view and switch to evidence_detail temporarily for context
saved_view = self.current_view saved_view = self.current_view
self.current_view = "evidence_detail" self.current_view = "evidence_detail"
self.dialog_add_note() self.dialog_add_note()
self.current_view = saved_view self.current_view = saved_view
# Continue loop to refresh with new note scroll_offset = max_scroll # Jump to bottom to show new note
elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc elif key == ord('b') or key == ord('B') or key == ord('q') or key == ord('Q') or key == 27: # 27 is Esc
break break
else:
# Any other key also closes (backwards compatibility)
break
def export_iocs(self): def export_iocs(self):
"""Export IOCs from current context to a text file""" """Export IOCs from current context to a text file"""