mirror of
https://github.com/overcuriousity/trace.git
synced 2025-12-20 13:02:21 +00:00
Merge pull request #4 from overcuriousity/claude/fix-note-details-access-01XmzZ6wE2NUAXba8tF6TrxD
Claude/fix note details access 01 xmz z6w e2 nua xba8t f6 trx d
This commit is contained in:
220
CONSISTENCY_ANALYSIS.md
Normal file
220
CONSISTENCY_ANALYSIS.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# Menu Interface Consistency Analysis
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Investigation of key bindings and behavior across all views in the trace TUI application to identify inconsistencies and improve user experience.
|
||||||
|
|
||||||
|
## Views in the Application
|
||||||
|
1. **case_list** - Main list of cases
|
||||||
|
2. **case_detail** - Case details with evidence list and case notes
|
||||||
|
3. **evidence_detail** - Evidence details with notes
|
||||||
|
4. **tags_list** - List of tags with usage counts
|
||||||
|
5. **tag_notes_list** - List of notes containing a specific tag
|
||||||
|
6. **ioc_list** - List of IOCs with usage counts
|
||||||
|
7. **ioc_notes_list** - List of notes containing a specific IOC
|
||||||
|
8. **note_detail** - Full view of a single note
|
||||||
|
9. **help** - Help screen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ What's CONSISTENT and Working Well
|
||||||
|
|
||||||
|
### Global Keys (work everywhere)
|
||||||
|
- **`?` or `h`**: Open help - Works from any view ✓
|
||||||
|
- **`q`**: Quit (or close help if in help view) ✓
|
||||||
|
- **`b`**: Back/navigate up hierarchy - Works consistently ✓
|
||||||
|
- **Arrow keys**: Navigate lists - Works consistently ✓
|
||||||
|
|
||||||
|
### Navigation Pattern
|
||||||
|
- **Enter**: Consistently opens/selects items or dives deeper ✓
|
||||||
|
- **`b`**: Consistently goes back up the hierarchy ✓
|
||||||
|
- Hierarchy navigation is logical and predictable ✓
|
||||||
|
|
||||||
|
### Note Actions in Main Views
|
||||||
|
- **`n`**: Add note - Works in case_list, case_detail, evidence_detail ✓
|
||||||
|
- **Enter on note**: Opens note_detail view - Now consistent across case_detail, evidence_detail, tag_notes_list, ioc_notes_list ✓
|
||||||
|
- **`v`**: View notes modal with highlight - Now consistent in case_detail and evidence_detail ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ INCONSISTENCIES Found
|
||||||
|
|
||||||
|
### 1. **'n' Key (Add Note) - Incomplete Coverage** ⚠️
|
||||||
|
**Issue**: The 'n' key only works in `case_list`, `case_detail`, and `evidence_detail`.
|
||||||
|
|
||||||
|
**Missing in**:
|
||||||
|
- `tags_list` - Pressing 'n' does nothing useful
|
||||||
|
- `tag_notes_list` - Pressing 'n' does nothing useful
|
||||||
|
- `ioc_list` - Pressing 'n' does nothing useful
|
||||||
|
- `ioc_notes_list` - Pressing 'n' does nothing useful
|
||||||
|
- `note_detail` - Pressing 'n' does nothing useful
|
||||||
|
- `help` - (OK to not work here)
|
||||||
|
|
||||||
|
**Expected behavior**: User should be able to add notes from tag/IOC views since they're actively working with a case/evidence context.
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
- Make 'n' work in `tag_notes_list` and `ioc_notes_list` by using the parent context (active_case or active_evidence)
|
||||||
|
- Consider whether it should work in `tags_list` and `ioc_list` as well
|
||||||
|
- In `note_detail`, 'n' could add a new note to the same context as the currently viewed note
|
||||||
|
|
||||||
|
**Code location**: `trace/tui.py:2242` - `dialog_add_note()` only handles 3 views
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **'a' Key (Set Active) - Incomplete Implementation** ⚠️
|
||||||
|
**Issue**: The global handler `key == ord('a')` calls `_handle_set_active()`, but that method only handles:
|
||||||
|
- `case_list`
|
||||||
|
- `case_detail`
|
||||||
|
- `evidence_detail`
|
||||||
|
|
||||||
|
**Missing in**:
|
||||||
|
- `tags_list` - Could set active on the parent case/evidence
|
||||||
|
- `tag_notes_list` - Silent failure when pressed
|
||||||
|
- `ioc_list` - Could set active on the parent case/evidence
|
||||||
|
- `ioc_notes_list` - Silent failure when pressed
|
||||||
|
- `note_detail` - Silent failure when pressed
|
||||||
|
|
||||||
|
**Expected behavior**: Either the key should work (set active on the parent context) or show a message that it's not applicable.
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
- Option A: Make 'a' work in tag/IOC list views by setting active on the parent case/evidence
|
||||||
|
- Option B: Show message "Not applicable in this view" when pressed in unsupported views
|
||||||
|
- Prefer Option A for consistency
|
||||||
|
|
||||||
|
**Code location**: `trace/tui.py:1512` - `_handle_set_active()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **'/' Key (Filter) - Limited Availability** ℹ️
|
||||||
|
**Issue**: Filter only works in `case_list` and `case_detail`.
|
||||||
|
|
||||||
|
**Missing in**:
|
||||||
|
- `evidence_detail` - Would be useful to filter notes!
|
||||||
|
- `tags_list` - Would be useful to filter tags
|
||||||
|
- `tag_notes_list` - Would be useful to filter notes
|
||||||
|
- `ioc_list` - Would be useful to filter IOCs
|
||||||
|
- `ioc_notes_list` - Would be useful to filter notes
|
||||||
|
|
||||||
|
**Expected behavior**: Users might expect to filter long lists of notes, tags, or IOCs.
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
- High priority: Add filtering to `evidence_detail` (filter notes by content)
|
||||||
|
- Medium priority: Add filtering to `tags_list`, `ioc_list` (filter by name/value)
|
||||||
|
- Lower priority: Add filtering to `tag_notes_list`, `ioc_notes_list` (filter notes by content)
|
||||||
|
|
||||||
|
**Code location**: `trace/tui.py:1236-1240` - Filter toggle only checks two views
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **'t' and 'i' Keys (Tags/IOCs) - Context-Dependent Availability** ℹ️
|
||||||
|
**Issue**: These keys only work in `case_detail` and `evidence_detail`.
|
||||||
|
|
||||||
|
**Current behavior**: Silent no-op in other views
|
||||||
|
|
||||||
|
**Expected behavior**: This is actually reasonable - tags and IOCs are context-specific. However, it might be confusing when users press them in `tag_notes_list` or `ioc_notes_list` and nothing happens.
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
- Low priority fix: Consider allowing 't' in `ioc_notes_list` to switch to tags view
|
||||||
|
- Low priority fix: Consider allowing 'i' in `tag_notes_list` to switch to IOCs view
|
||||||
|
- This would allow quick toggling between tag and IOC exploration
|
||||||
|
- Alternative: Show helpful message if pressed in unsupported views
|
||||||
|
|
||||||
|
**Code location**: `trace/tui.py:1449-1452` - No view restrictions, but handlers at lines 182 and 206 check view
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. **'d' Key (Delete) - Incomplete Coverage** ⚠️
|
||||||
|
**Issue**: Delete functionality is not implemented for all views where deletion makes sense.
|
||||||
|
|
||||||
|
**Works in**:
|
||||||
|
- `case_list` - Delete case ✓
|
||||||
|
- `case_detail` - Delete evidence or note ✓
|
||||||
|
- `evidence_detail` - Delete note ✓
|
||||||
|
|
||||||
|
**Missing in**:
|
||||||
|
- `tag_notes_list` - Could delete the selected note
|
||||||
|
- `ioc_notes_list` - Could delete the selected note
|
||||||
|
- `note_detail` - Could delete the currently viewed note
|
||||||
|
|
||||||
|
**Expected behavior**: Users viewing a note should be able to delete it from any view.
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
- Add delete support for `tag_notes_list` and `ioc_notes_list`
|
||||||
|
- Add delete support for `note_detail` (delete current note and return to previous view)
|
||||||
|
- Be careful with confirmation dialogs to prevent accidental deletion
|
||||||
|
|
||||||
|
**Code location**: `trace/tui.py:2331` - `handle_delete()` method
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. **'v' Key (View Notes Modal) - Limited Availability** ℹ️
|
||||||
|
**Issue**: View notes modal only works in `case_detail` and `evidence_detail`.
|
||||||
|
|
||||||
|
**Missing in**:
|
||||||
|
- `tag_notes_list` - Could show all notes with the tag in a modal
|
||||||
|
- `ioc_notes_list` - Could show all notes with the IOC in a modal
|
||||||
|
|
||||||
|
**Expected behavior**: Might be nice to have a modal view option from tag/IOC note lists, but not critical since they're already list views.
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
- Low priority: This might be redundant since tag_notes_list and ioc_notes_list are already list views
|
||||||
|
- Consider if there's a different modal that would be useful (e.g., showing just the tag/IOC highlights)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. **'e' Key (Export) - Very Limited** ℹ️
|
||||||
|
**Issue**: Export only works for IOCs in `ioc_list` and `ioc_notes_list`.
|
||||||
|
|
||||||
|
**Missing export options**:
|
||||||
|
- Export tags from `tags_list`
|
||||||
|
- Export notes from various views
|
||||||
|
- Export case summary from `case_detail`
|
||||||
|
|
||||||
|
**Expected behavior**: Users might expect export functionality for other data types.
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
- Medium priority: Add 'e' to export tags from `tags_list`
|
||||||
|
- Lower priority: Consider export options for notes, cases, evidence
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Recommendations
|
||||||
|
|
||||||
|
### High Priority (User expects it to work)
|
||||||
|
1. **Fix 'n' (add note) in tag/IOC note lists** - Users actively working with notes should be able to add new ones
|
||||||
|
2. **Fix 'a' (set active) to work or give feedback** - Silent failures are confusing
|
||||||
|
3. **Add filtering to evidence_detail** - Natural extension of existing filter functionality
|
||||||
|
|
||||||
|
### Medium Priority (Nice to have)
|
||||||
|
4. **Add delete support for tag/IOC note lists and note_detail** - Complete the delete functionality
|
||||||
|
5. **Add filter to tag and IOC lists** - Helpful for large numbers of items
|
||||||
|
6. **Make 't' and 'i' keys provide feedback** - Better UX than silent failure
|
||||||
|
|
||||||
|
### Low Priority (Edge cases)
|
||||||
|
7. **Cross-navigation between tags and IOCs** - Allow 't' in IOC views and 'i' in tag views
|
||||||
|
8. **Export tags** - Complement the export IOCs functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General Observations
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
- Core navigation is very consistent (Enter, b, arrows)
|
||||||
|
- The hierarchy is logical and predictable
|
||||||
|
- Global keys work well everywhere
|
||||||
|
- Recent fixes made note navigation consistent ✓
|
||||||
|
|
||||||
|
### Areas for Improvement
|
||||||
|
- Feature keys ('n', 'a', 'd', 't', 'i', 'v') should either work everywhere sensible OR provide clear feedback when not applicable
|
||||||
|
- Filtering could be more universally available
|
||||||
|
- Delete functionality should be available wherever items can be viewed
|
||||||
|
- Silent failures (pressing a key and nothing happening) should be minimized
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
Create a testing matrix:
|
||||||
|
- Test each key in each view
|
||||||
|
- Document expected behavior
|
||||||
|
- Mark any silent failures
|
||||||
|
- Ensure error messages are helpful when actions aren't available
|
||||||
409
trace/tui.py
409
trace/tui.py
@@ -781,7 +781,7 @@ class TUI:
|
|||||||
is_selected = (item_idx == self.selected_index)
|
is_selected = (item_idx == self.selected_index)
|
||||||
self._display_line_with_highlights(y, 4, display_str, is_selected)
|
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 [e] Export [a] Active [d] Delete [?] Help", curses.color_pair(3))
|
||||||
|
|
||||||
def draw_evidence_detail(self):
|
def draw_evidence_detail(self):
|
||||||
if not self.active_evidence: return
|
if not self.active_evidence: return
|
||||||
@@ -812,14 +812,17 @@ class TUI:
|
|||||||
current_y += 1
|
current_y += 1
|
||||||
|
|
||||||
current_y += 1 # Blank line before notes
|
current_y += 1 # Blank line before notes
|
||||||
self.stdscr.addstr(current_y, 2, f"Notes ({len(self.active_evidence.notes)}):", curses.A_UNDERLINE)
|
|
||||||
|
# Apply filter if active
|
||||||
|
notes = self._get_filtered_list(self.active_evidence.notes, "content") if self.filter_query else self.active_evidence.notes
|
||||||
|
|
||||||
|
self.stdscr.addstr(current_y, 2, f"Notes ({len(notes)}):", curses.A_UNDERLINE)
|
||||||
current_y += 1
|
current_y += 1
|
||||||
|
|
||||||
# Just show last N notes that fit
|
# Just show last N notes that fit
|
||||||
list_h = self.content_h - (current_y - 2) # Adjust for dynamic header
|
list_h = self.content_h - (current_y - 2) # Adjust for dynamic header
|
||||||
start_y = current_y
|
start_y = current_y
|
||||||
|
|
||||||
notes = self.active_evidence.notes
|
|
||||||
display_notes = notes[-list_h:] if len(notes) > list_h else notes
|
display_notes = notes[-list_h:] if len(notes) > list_h else notes
|
||||||
|
|
||||||
# Update scroll for note selection
|
# Update scroll for note selection
|
||||||
@@ -841,7 +844,10 @@ class TUI:
|
|||||||
is_selected = (idx == self.selected_index)
|
is_selected = (idx == self.selected_index)
|
||||||
self._display_line_with_highlights(start_y + i, 4, display_str, is_selected)
|
self._display_line_with_highlights(start_y + i, 4, display_str, is_selected)
|
||||||
|
|
||||||
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))
|
footer = "[n] Add Note [t] Tags [i] IOCs [v] View [e] Export [a] Active [d] Delete [/] Filter [?] Help"
|
||||||
|
if self.filter_query:
|
||||||
|
footer += f" Filter: {self.filter_query}"
|
||||||
|
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
|
||||||
|
|
||||||
def draw_tags_list(self):
|
def draw_tags_list(self):
|
||||||
"""Draw the tags list view showing all tags sorted by occurrence count"""
|
"""Draw the tags list view showing all tags sorted by occurrence count"""
|
||||||
@@ -851,19 +857,29 @@ class TUI:
|
|||||||
self.stdscr.addstr(2, 2, f"Tags for {context}: {context_name}", curses.A_BOLD)
|
self.stdscr.addstr(2, 2, f"Tags for {context}: {context_name}", curses.A_BOLD)
|
||||||
self.stdscr.addstr(3, 2, "─" * (self.width - 4))
|
self.stdscr.addstr(3, 2, "─" * (self.width - 4))
|
||||||
|
|
||||||
if not self.current_tags:
|
# Apply filter if active (filter by tag name)
|
||||||
self.stdscr.addstr(5, 4, "No tags found.", curses.color_pair(3))
|
tags_to_show = self.current_tags
|
||||||
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3))
|
if self.filter_query:
|
||||||
|
q = self.filter_query.lower()
|
||||||
|
tags_to_show = [(tag, count) for tag, count in self.current_tags if q in tag.lower()]
|
||||||
|
|
||||||
|
if not tags_to_show:
|
||||||
|
msg = "No tags match filter." if self.filter_query else "No tags found."
|
||||||
|
self.stdscr.addstr(5, 4, msg, curses.color_pair(3))
|
||||||
|
footer = "[b] Back [/] Filter"
|
||||||
|
if self.filter_query:
|
||||||
|
footer += f" Filter: {self.filter_query}"
|
||||||
|
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
|
||||||
return
|
return
|
||||||
|
|
||||||
list_h = self._update_scroll(len(self.current_tags))
|
list_h = self._update_scroll(len(tags_to_show))
|
||||||
|
|
||||||
for i in range(list_h):
|
for i in range(list_h):
|
||||||
idx = self.scroll_offset + i
|
idx = self.scroll_offset + i
|
||||||
if idx >= len(self.current_tags):
|
if idx >= len(tags_to_show):
|
||||||
break
|
break
|
||||||
|
|
||||||
tag, count = self.current_tags[idx]
|
tag, count = tags_to_show[idx]
|
||||||
y = 5 + i
|
y = 5 + i
|
||||||
|
|
||||||
display_str = f"#{tag}".ljust(30) + f"({count} notes)"
|
display_str = f"#{tag}".ljust(30) + f"({count} notes)"
|
||||||
@@ -876,26 +892,36 @@ class TUI:
|
|||||||
else:
|
else:
|
||||||
self.stdscr.addstr(y, 4, display_str)
|
self.stdscr.addstr(y, 4, display_str)
|
||||||
|
|
||||||
self.stdscr.addstr(self.height - 3, 2, "[Enter] View Notes [b] Back", curses.color_pair(3))
|
footer = "[Enter] View Notes [b] Back [/] Filter"
|
||||||
|
if self.filter_query:
|
||||||
|
footer += f" Filter: {self.filter_query}"
|
||||||
|
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
|
||||||
|
|
||||||
def draw_tag_notes_list(self):
|
def draw_tag_notes_list(self):
|
||||||
"""Draw compact list of notes containing the selected tag"""
|
"""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)
|
# Apply filter if active
|
||||||
|
notes_to_show = self._get_filtered_list(self.tag_notes, "content") if self.filter_query else self.tag_notes
|
||||||
|
|
||||||
|
self.stdscr.addstr(2, 2, f"Notes tagged with #{self.current_tag} ({len(notes_to_show)})", curses.A_BOLD)
|
||||||
self.stdscr.addstr(3, 2, "─" * (self.width - 4))
|
self.stdscr.addstr(3, 2, "─" * (self.width - 4))
|
||||||
|
|
||||||
if not self.tag_notes:
|
if not notes_to_show:
|
||||||
self.stdscr.addstr(5, 4, "No notes found.", curses.color_pair(3))
|
msg = "No notes match filter." if self.filter_query else "No notes found."
|
||||||
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3))
|
self.stdscr.addstr(5, 4, msg, curses.color_pair(3))
|
||||||
|
footer = "[b] Back [/] Filter"
|
||||||
|
if self.filter_query:
|
||||||
|
footer += f" Filter: {self.filter_query}"
|
||||||
|
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
|
||||||
return
|
return
|
||||||
|
|
||||||
list_h = self._update_scroll(len(self.tag_notes))
|
list_h = self._update_scroll(len(notes_to_show))
|
||||||
|
|
||||||
for i in range(list_h):
|
for i in range(list_h):
|
||||||
idx = self.scroll_offset + i
|
idx = self.scroll_offset + i
|
||||||
if idx >= len(self.tag_notes):
|
if idx >= len(notes_to_show):
|
||||||
break
|
break
|
||||||
|
|
||||||
note = self.tag_notes[idx]
|
note = notes_to_show[idx]
|
||||||
y = 5 + i
|
y = 5 + i
|
||||||
|
|
||||||
timestamp_str = time.ctime(note.timestamp)
|
timestamp_str = time.ctime(note.timestamp)
|
||||||
@@ -914,7 +940,10 @@ class TUI:
|
|||||||
else:
|
else:
|
||||||
self.stdscr.addstr(y, 4, display_str)
|
self.stdscr.addstr(y, 4, display_str)
|
||||||
|
|
||||||
self.stdscr.addstr(self.height - 3, 2, "[Enter] Expand [b] Back", curses.color_pair(3))
|
footer = "[Enter] Expand [d] Delete [b] Back [/] Filter"
|
||||||
|
if self.filter_query:
|
||||||
|
footer += f" Filter: {self.filter_query}"
|
||||||
|
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
|
||||||
|
|
||||||
def draw_ioc_list(self):
|
def draw_ioc_list(self):
|
||||||
"""Draw the IOC list view showing all IOCs sorted by occurrence count"""
|
"""Draw the IOC list view showing all IOCs sorted by occurrence count"""
|
||||||
@@ -924,19 +953,30 @@ class TUI:
|
|||||||
self.stdscr.addstr(2, 2, f"IOCs for {context}: {context_name}", curses.A_BOLD)
|
self.stdscr.addstr(2, 2, f"IOCs for {context}: {context_name}", curses.A_BOLD)
|
||||||
self.stdscr.addstr(3, 2, "─" * (self.width - 4))
|
self.stdscr.addstr(3, 2, "─" * (self.width - 4))
|
||||||
|
|
||||||
if not self.current_iocs:
|
# Apply filter if active (filter by IOC value or type)
|
||||||
self.stdscr.addstr(5, 4, "No IOCs found.", curses.color_pair(3))
|
iocs_to_show = self.current_iocs
|
||||||
self.stdscr.addstr(self.height - 3, 2, "[b] Back [e] Export", curses.color_pair(3))
|
if self.filter_query:
|
||||||
|
q = self.filter_query.lower()
|
||||||
|
iocs_to_show = [(ioc, count, ioc_type) for ioc, count, ioc_type in self.current_iocs
|
||||||
|
if q in ioc.lower() or q in ioc_type.lower()]
|
||||||
|
|
||||||
|
if not iocs_to_show:
|
||||||
|
msg = "No IOCs match filter." if self.filter_query else "No IOCs found."
|
||||||
|
self.stdscr.addstr(5, 4, msg, curses.color_pair(3))
|
||||||
|
footer = "[b] Back [e] Export [/] Filter"
|
||||||
|
if self.filter_query:
|
||||||
|
footer += f" Filter: {self.filter_query}"
|
||||||
|
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
|
||||||
return
|
return
|
||||||
|
|
||||||
list_h = self._update_scroll(len(self.current_iocs))
|
list_h = self._update_scroll(len(iocs_to_show))
|
||||||
|
|
||||||
for i in range(list_h):
|
for i in range(list_h):
|
||||||
idx = self.scroll_offset + i
|
idx = self.scroll_offset + i
|
||||||
if idx >= len(self.current_iocs):
|
if idx >= len(iocs_to_show):
|
||||||
break
|
break
|
||||||
|
|
||||||
ioc, count, ioc_type = self.current_iocs[idx]
|
ioc, count, ioc_type = iocs_to_show[idx]
|
||||||
y = 5 + i
|
y = 5 + i
|
||||||
|
|
||||||
# Show IOC with type indicator and count in red
|
# Show IOC with type indicator and count in red
|
||||||
@@ -953,26 +993,36 @@ class TUI:
|
|||||||
self.stdscr.addstr(y, 4, display_str)
|
self.stdscr.addstr(y, 4, display_str)
|
||||||
self.stdscr.attroff(curses.color_pair(4))
|
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))
|
footer = "[Enter] View Notes [e] Export [b] Back [/] Filter"
|
||||||
|
if self.filter_query:
|
||||||
|
footer += f" Filter: {self.filter_query}"
|
||||||
|
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
|
||||||
|
|
||||||
def draw_ioc_notes_list(self):
|
def draw_ioc_notes_list(self):
|
||||||
"""Draw compact list of notes containing the selected IOC"""
|
"""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)
|
# Apply filter if active
|
||||||
|
notes_to_show = self._get_filtered_list(self.ioc_notes, "content") if self.filter_query else self.ioc_notes
|
||||||
|
|
||||||
|
self.stdscr.addstr(2, 2, f"Notes with IOC: {self.current_ioc} ({len(notes_to_show)})", curses.A_BOLD)
|
||||||
self.stdscr.addstr(3, 2, "─" * (self.width - 4))
|
self.stdscr.addstr(3, 2, "─" * (self.width - 4))
|
||||||
|
|
||||||
if not self.ioc_notes:
|
if not notes_to_show:
|
||||||
self.stdscr.addstr(5, 4, "No notes found.", curses.color_pair(3))
|
msg = "No notes match filter." if self.filter_query else "No notes found."
|
||||||
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3))
|
self.stdscr.addstr(5, 4, msg, curses.color_pair(3))
|
||||||
|
footer = "[b] Back [e] Export [/] Filter"
|
||||||
|
if self.filter_query:
|
||||||
|
footer += f" Filter: {self.filter_query}"
|
||||||
|
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
|
||||||
return
|
return
|
||||||
|
|
||||||
list_h = self._update_scroll(len(self.ioc_notes))
|
list_h = self._update_scroll(len(notes_to_show))
|
||||||
|
|
||||||
for i in range(list_h):
|
for i in range(list_h):
|
||||||
idx = self.scroll_offset + i
|
idx = self.scroll_offset + i
|
||||||
if idx >= len(self.ioc_notes):
|
if idx >= len(notes_to_show):
|
||||||
break
|
break
|
||||||
|
|
||||||
note = self.ioc_notes[idx]
|
note = notes_to_show[idx]
|
||||||
y = 5 + i
|
y = 5 + i
|
||||||
|
|
||||||
timestamp_str = time.ctime(note.timestamp)
|
timestamp_str = time.ctime(note.timestamp)
|
||||||
@@ -988,7 +1038,10 @@ class TUI:
|
|||||||
else:
|
else:
|
||||||
self.stdscr.addstr(y, 4, display_str)
|
self.stdscr.addstr(y, 4, display_str)
|
||||||
|
|
||||||
self.stdscr.addstr(self.height - 3, 2, "[Enter] Expand [b] Back", curses.color_pair(3))
|
footer = "[Enter] Expand [d] Delete [e] Export [b] Back [/] Filter"
|
||||||
|
if self.filter_query:
|
||||||
|
footer += f" Filter: {self.filter_query}"
|
||||||
|
self.stdscr.addstr(self.height - 3, 2, footer[:self.width - 4], curses.color_pair(3))
|
||||||
|
|
||||||
def draw_note_detail(self):
|
def draw_note_detail(self):
|
||||||
"""Draw expanded view of a single note with all details"""
|
"""Draw expanded view of a single note with all details"""
|
||||||
@@ -1050,7 +1103,7 @@ class TUI:
|
|||||||
self.stdscr.addstr(current_y, 2, "Signature: [GPG signed]", curses.color_pair(2))
|
self.stdscr.addstr(current_y, 2, "Signature: [GPG signed]", curses.color_pair(2))
|
||||||
current_y += 1
|
current_y += 1
|
||||||
|
|
||||||
self.stdscr.addstr(self.height - 3, 2, "[b] Back", curses.color_pair(3))
|
self.stdscr.addstr(self.height - 3, 2, "[d] Delete [b] Back", curses.color_pair(3))
|
||||||
|
|
||||||
def draw_help(self):
|
def draw_help(self):
|
||||||
"""Draw the help screen with keyboard shortcuts and features"""
|
"""Draw the help screen with keyboard shortcuts and features"""
|
||||||
@@ -1234,8 +1287,8 @@ class TUI:
|
|||||||
|
|
||||||
# Filter toggle
|
# Filter toggle
|
||||||
if key == ord('/'):
|
if key == ord('/'):
|
||||||
# Filter works on list views: case_list and case_detail (evidence list)
|
# Filter works on all list views (context-sensitive)
|
||||||
if self.current_view in ["case_list", "case_detail"]:
|
if self.current_view in ["case_list", "case_detail", "evidence_detail", "tags_list", "tag_notes_list", "ioc_list", "ioc_notes_list"]:
|
||||||
self.filter_mode = True
|
self.filter_mode = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1298,15 +1351,14 @@ class TUI:
|
|||||||
notes = self.active_evidence.notes
|
notes = self.active_evidence.notes
|
||||||
list_h = self.content_h - 5
|
list_h = self.content_h - 5
|
||||||
display_notes = notes[-list_h:] if len(notes) > list_h else notes
|
display_notes = notes[-list_h:] if len(notes) > list_h else notes
|
||||||
|
|
||||||
if display_notes and self.selected_index < len(display_notes):
|
if display_notes and self.selected_index < len(display_notes):
|
||||||
# Calculate the actual note index in the full list
|
# Show note detail view (consistent with other views)
|
||||||
note_offset = len(notes) - len(display_notes)
|
self.current_note = display_notes[self.selected_index]
|
||||||
actual_note_index = note_offset + self.selected_index
|
self.previous_view = "evidence_detail"
|
||||||
# Open notes view and jump to selected note
|
self.current_view = "note_detail"
|
||||||
self._highlight_note_idx = actual_note_index
|
self.selected_index = 0
|
||||||
self.view_evidence_notes(highlight_note_index=actual_note_index)
|
self.scroll_offset = 0
|
||||||
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
|
case_notes = self.active_case.notes
|
||||||
@@ -1427,10 +1479,14 @@ class TUI:
|
|||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
self.filter_query = ""
|
self.filter_query = ""
|
||||||
|
|
||||||
# Export IOCs
|
# Export
|
||||||
elif key == ord('e'):
|
elif key == ord('e'):
|
||||||
if self.current_view in ["ioc_list", "ioc_notes_list"]:
|
if self.current_view in ["ioc_list", "ioc_notes_list"]:
|
||||||
self.export_iocs()
|
self.export_iocs()
|
||||||
|
elif self.current_view == "case_detail":
|
||||||
|
self.export_case_markdown()
|
||||||
|
elif self.current_view == "evidence_detail":
|
||||||
|
self.export_evidence_markdown()
|
||||||
|
|
||||||
# Set Active
|
# Set Active
|
||||||
elif key == ord('a'):
|
elif key == ord('a'):
|
||||||
@@ -1453,9 +1509,34 @@ class TUI:
|
|||||||
self.handle_open_iocs()
|
self.handle_open_iocs()
|
||||||
elif key == ord('v'):
|
elif key == ord('v'):
|
||||||
if self.current_view == "case_detail":
|
if self.current_view == "case_detail":
|
||||||
self.view_case_notes()
|
# Open notes modal with selected case note highlighted (if applicable)
|
||||||
|
if self.active_case:
|
||||||
|
case_notes = self.active_case.notes
|
||||||
|
filtered = self._get_filtered_list(self.active_case.evidence, "name", "description")
|
||||||
|
|
||||||
|
# Check if a case note is selected (not an evidence item)
|
||||||
|
if case_notes and self.selected_index >= len(filtered):
|
||||||
|
note_idx = self.selected_index - len(filtered)
|
||||||
|
if note_idx < len(case_notes):
|
||||||
|
self.view_case_notes(highlight_note_index=note_idx)
|
||||||
|
else:
|
||||||
|
self.view_case_notes()
|
||||||
|
else:
|
||||||
|
self.view_case_notes()
|
||||||
elif self.current_view == "evidence_detail":
|
elif self.current_view == "evidence_detail":
|
||||||
self.view_evidence_notes()
|
# Open notes modal with selected note highlighted
|
||||||
|
if self.active_evidence:
|
||||||
|
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
|
||||||
|
self.view_evidence_notes(highlight_note_index=actual_note_index)
|
||||||
|
else:
|
||||||
|
self.view_evidence_notes()
|
||||||
|
|
||||||
# Delete
|
# Delete
|
||||||
elif key == ord('d'):
|
elif key == ord('d'):
|
||||||
@@ -2382,6 +2463,106 @@ class TUI:
|
|||||||
self.scroll_offset = 0
|
self.scroll_offset = 0
|
||||||
self.show_message("Note deleted.")
|
self.show_message("Note deleted.")
|
||||||
|
|
||||||
|
elif self.current_view == "note_detail":
|
||||||
|
# Delete the currently viewed note
|
||||||
|
if not self.current_note:
|
||||||
|
return
|
||||||
|
|
||||||
|
preview = self.current_note.content[:50] + "..." if len(self.current_note.content) > 50 else self.current_note.content
|
||||||
|
if self.dialog_confirm(f"Delete note: '{preview}'?"):
|
||||||
|
# Find and delete the note from its parent (case or evidence)
|
||||||
|
deleted = False
|
||||||
|
# Check all cases and their evidence for this note
|
||||||
|
for case in self.cases:
|
||||||
|
if self.current_note in case.notes:
|
||||||
|
case.notes.remove(self.current_note)
|
||||||
|
deleted = True
|
||||||
|
break
|
||||||
|
for ev in case.evidence:
|
||||||
|
if self.current_note in ev.notes:
|
||||||
|
ev.notes.remove(self.current_note)
|
||||||
|
deleted = True
|
||||||
|
break
|
||||||
|
if deleted:
|
||||||
|
break
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
self.storage.save_data()
|
||||||
|
self.show_message("Note deleted.")
|
||||||
|
# Return to previous view
|
||||||
|
self.current_view = getattr(self, 'previous_view', 'case_detail')
|
||||||
|
self.current_note = None
|
||||||
|
self.selected_index = 0
|
||||||
|
self.scroll_offset = 0
|
||||||
|
else:
|
||||||
|
self.show_message("Error: Note not found.")
|
||||||
|
|
||||||
|
elif self.current_view == "tag_notes_list":
|
||||||
|
# Delete selected note from tag notes list
|
||||||
|
if not self.tag_notes or self.selected_index >= len(self.tag_notes):
|
||||||
|
return
|
||||||
|
|
||||||
|
note_to_del = self.tag_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}'?"):
|
||||||
|
# Find and delete the note from its parent
|
||||||
|
deleted = False
|
||||||
|
for case in self.cases:
|
||||||
|
if note_to_del in case.notes:
|
||||||
|
case.notes.remove(note_to_del)
|
||||||
|
deleted = True
|
||||||
|
break
|
||||||
|
for ev in case.evidence:
|
||||||
|
if note_to_del in ev.notes:
|
||||||
|
ev.notes.remove(note_to_del)
|
||||||
|
deleted = True
|
||||||
|
break
|
||||||
|
if deleted:
|
||||||
|
break
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
self.storage.save_data()
|
||||||
|
# Remove from tag_notes list as well
|
||||||
|
self.tag_notes.remove(note_to_del)
|
||||||
|
self.selected_index = min(self.selected_index, len(self.tag_notes) - 1) if self.tag_notes else 0
|
||||||
|
self.scroll_offset = 0
|
||||||
|
self.show_message("Note deleted.")
|
||||||
|
else:
|
||||||
|
self.show_message("Error: Note not found.")
|
||||||
|
|
||||||
|
elif self.current_view == "ioc_notes_list":
|
||||||
|
# Delete selected note from IOC notes list
|
||||||
|
if not self.ioc_notes or self.selected_index >= len(self.ioc_notes):
|
||||||
|
return
|
||||||
|
|
||||||
|
note_to_del = self.ioc_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}'?"):
|
||||||
|
# Find and delete the note from its parent
|
||||||
|
deleted = False
|
||||||
|
for case in self.cases:
|
||||||
|
if note_to_del in case.notes:
|
||||||
|
case.notes.remove(note_to_del)
|
||||||
|
deleted = True
|
||||||
|
break
|
||||||
|
for ev in case.evidence:
|
||||||
|
if note_to_del in ev.notes:
|
||||||
|
ev.notes.remove(note_to_del)
|
||||||
|
deleted = True
|
||||||
|
break
|
||||||
|
if deleted:
|
||||||
|
break
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
self.storage.save_data()
|
||||||
|
# Remove from ioc_notes list as well
|
||||||
|
self.ioc_notes.remove(note_to_del)
|
||||||
|
self.selected_index = min(self.selected_index, len(self.ioc_notes) - 1) if self.ioc_notes else 0
|
||||||
|
self.scroll_offset = 0
|
||||||
|
self.show_message("Note deleted.")
|
||||||
|
else:
|
||||||
|
self.show_message("Error: Note not found.")
|
||||||
|
|
||||||
def view_case_notes(self, highlight_note_index=None):
|
def view_case_notes(self, highlight_note_index=None):
|
||||||
if not self.active_case: return
|
if not self.active_case: return
|
||||||
|
|
||||||
@@ -2685,6 +2866,138 @@ class TUI:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.show_message(f"Export failed: {str(e)}")
|
self.show_message(f"Export failed: {str(e)}")
|
||||||
|
|
||||||
|
def export_case_markdown(self):
|
||||||
|
"""Export current case (and all its evidence) to markdown"""
|
||||||
|
if not self.active_case:
|
||||||
|
self.show_message("No active case to export.")
|
||||||
|
return
|
||||||
|
|
||||||
|
import os
|
||||||
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Create exports directory if it doesn't exist
|
||||||
|
export_dir = Path.home() / ".trace" / "exports"
|
||||||
|
export_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate filename
|
||||||
|
case_name = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in self.active_case.case_number)
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"case_{case_name}_{timestamp}.md"
|
||||||
|
filepath = export_dir / filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
f.write("# Forensic Notes Export\n\n")
|
||||||
|
f.write(f"Generated on: {time.ctime()}\n\n")
|
||||||
|
|
||||||
|
# Write case info
|
||||||
|
f.write(f"## Case: {self.active_case.case_number}\n")
|
||||||
|
if self.active_case.name:
|
||||||
|
f.write(f"**Name:** {self.active_case.name}\n")
|
||||||
|
if self.active_case.investigator:
|
||||||
|
f.write(f"**Investigator:** {self.active_case.investigator}\n")
|
||||||
|
f.write(f"**Case ID:** {self.active_case.case_id}\n\n")
|
||||||
|
|
||||||
|
# Case notes
|
||||||
|
f.write("### Case Notes\n")
|
||||||
|
if not self.active_case.notes:
|
||||||
|
f.write("_No notes._\n")
|
||||||
|
for note in self.active_case.notes:
|
||||||
|
self._write_note_markdown(f, note)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
# Evidence
|
||||||
|
f.write("### Evidence\n")
|
||||||
|
if not self.active_case.evidence:
|
||||||
|
f.write("_No evidence items._\n")
|
||||||
|
for ev in self.active_case.evidence:
|
||||||
|
f.write(f"#### {ev.name}\n")
|
||||||
|
if ev.description:
|
||||||
|
f.write(f"**Description:** {ev.description}\n")
|
||||||
|
if ev.metadata.get("source_hash"):
|
||||||
|
f.write(f"**Source Hash:** `{ev.metadata['source_hash']}`\n")
|
||||||
|
f.write(f"**Evidence ID:** {ev.evidence_id}\n\n")
|
||||||
|
|
||||||
|
f.write("**Notes:**\n")
|
||||||
|
if not ev.notes:
|
||||||
|
f.write("_No notes._\n")
|
||||||
|
for note in ev.notes:
|
||||||
|
self._write_note_markdown(f, note)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
self.show_message(f"Case exported to: {filepath}")
|
||||||
|
except Exception as e:
|
||||||
|
self.show_message(f"Export failed: {str(e)}")
|
||||||
|
|
||||||
|
def export_evidence_markdown(self):
|
||||||
|
"""Export current evidence to markdown"""
|
||||||
|
if not self.active_evidence:
|
||||||
|
self.show_message("No active evidence to export.")
|
||||||
|
return
|
||||||
|
|
||||||
|
import os
|
||||||
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Create exports directory if it doesn't exist
|
||||||
|
export_dir = Path.home() / ".trace" / "exports"
|
||||||
|
export_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate filename
|
||||||
|
case_name = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in self.active_case.case_number) if self.active_case else "unknown"
|
||||||
|
ev_name = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in self.active_evidence.name)
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"evidence_{case_name}_{ev_name}_{timestamp}.md"
|
||||||
|
filepath = export_dir / filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
f.write("# Forensic Evidence Export\n\n")
|
||||||
|
f.write(f"Generated on: {time.ctime()}\n\n")
|
||||||
|
|
||||||
|
# Case context
|
||||||
|
if self.active_case:
|
||||||
|
f.write(f"**Case:** {self.active_case.case_number}\n")
|
||||||
|
if self.active_case.name:
|
||||||
|
f.write(f"**Case Name:** {self.active_case.name}\n")
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
# Evidence info
|
||||||
|
f.write(f"## Evidence: {self.active_evidence.name}\n")
|
||||||
|
if self.active_evidence.description:
|
||||||
|
f.write(f"**Description:** {self.active_evidence.description}\n")
|
||||||
|
if self.active_evidence.metadata.get("source_hash"):
|
||||||
|
f.write(f"**Source Hash:** `{self.active_evidence.metadata['source_hash']}`\n")
|
||||||
|
f.write(f"**Evidence ID:** {self.active_evidence.evidence_id}\n\n")
|
||||||
|
|
||||||
|
# Notes
|
||||||
|
f.write("### Notes\n")
|
||||||
|
if not self.active_evidence.notes:
|
||||||
|
f.write("_No notes._\n")
|
||||||
|
for note in self.active_evidence.notes:
|
||||||
|
self._write_note_markdown(f, note)
|
||||||
|
|
||||||
|
self.show_message(f"Evidence exported to: {filepath}")
|
||||||
|
except Exception as e:
|
||||||
|
self.show_message(f"Export failed: {str(e)}")
|
||||||
|
|
||||||
|
def _write_note_markdown(self, f, note):
|
||||||
|
"""Helper to write a note in markdown format"""
|
||||||
|
f.write(f"- **{time.ctime(note.timestamp)}**\n")
|
||||||
|
f.write(f" - Content: {note.content}\n")
|
||||||
|
if note.tags:
|
||||||
|
tags_str = " ".join([f"#{tag}" for tag in note.tags])
|
||||||
|
f.write(f" - Tags: {tags_str}\n")
|
||||||
|
f.write(f" - Hash: `{note.content_hash}`\n")
|
||||||
|
if note.signature:
|
||||||
|
f.write(" - **Signature Verified:**\n")
|
||||||
|
f.write(" ```\n")
|
||||||
|
for line in note.signature.splitlines():
|
||||||
|
f.write(f" {line}\n")
|
||||||
|
f.write(" ```\n")
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
def run_tui(open_active=False):
|
def run_tui(open_active=False):
|
||||||
"""
|
"""
|
||||||
Run the TUI application.
|
Run the TUI application.
|
||||||
|
|||||||
Reference in New Issue
Block a user