mirror of
https://github.com/overcuriousity/trace.git
synced 2025-12-20 13:02:21 +00:00
Add comprehensive menu consistency improvements
Based on UX analysis, added three major improvements:
1. Context-sensitive filtering everywhere ('/' key):
- evidence_detail: Filter notes by content
- tags_list: Filter tags by name
- tag_notes_list: Filter notes by content
- ioc_list: Filter IOCs by value or type
- ioc_notes_list: Filter notes by content
- All views show active filter in footer
2. Extended delete support ('d' key):
- note_detail: Delete current note and return to previous view
- tag_notes_list: Delete selected note from filtered view
- ioc_notes_list: Delete selected note from filtered view
- Finds and removes note from parent case/evidence
3. Markdown export for case/evidence ('e' key):
- case_detail: Export entire case + all evidence to markdown
- evidence_detail: Export single evidence item to markdown
- Files saved to ~/.trace/exports/ with timestamps
- Complements existing IOC export functionality
All changes maintain consistent UX patterns across views and
provide clear feedback through updated footers showing available
actions in each context.
This commit is contained in:
365
trace/tui.py
365
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
|
||||||
|
|
||||||
@@ -1426,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'):
|
||||||
@@ -2406,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
|
||||||
|
|
||||||
@@ -2709,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