Fix UTF-8/umlaut support in note input

Added full Unicode character support to the TUI's multiline input dialog.
Previously, only ASCII characters (32-126) were captured when typing notes.

Changes:
- Added UTF-8 multibyte character handling to _multiline_input_dialog()
- Properly collects and decodes 2/3/4-byte UTF-8 sequences
- Added explicit UTF-8 encoding to all file I/O operations
- Added ensure_ascii=False to JSON serialization for proper Unicode preservation

This fix allows users to enter umlauts (ä, ö, ü), accented characters
(é, à, ñ), and other Unicode characters in notes, case names, and all
other text input fields.

Tested with German umlauts, Asian characters, Cyrillic, and special chars.
This commit is contained in:
Claude
2025-12-13 11:42:37 +00:00
parent 3c53969b45
commit e38b018e41
3 changed files with 48 additions and 12 deletions

View File

@@ -69,7 +69,7 @@ def export_markdown(output_file: str = "export.md"):
try: try:
storage = Storage() storage = Storage()
with open(output_file, "w") as f: with open(output_file, "w", encoding='utf-8') as f:
f.write("# Forensic Notes Export\n\n") f.write("# Forensic Notes Export\n\n")
f.write(f"Generated on: {time.ctime()}\n\n") f.write(f"Generated on: {time.ctime()}\n\n")

View File

@@ -166,7 +166,7 @@ Attachment: invoice.pdf.exe (double extension trick) #email-forensics #phishing-
if not self.data_file.exists(): if not self.data_file.exists():
return [] return []
try: try:
with open(self.data_file, 'r') as f: with open(self.data_file, 'r', encoding='utf-8') as f:
data = json.load(f) data = json.load(f)
return [Case.from_dict(c) for c in data] return [Case.from_dict(c) for c in data]
except (json.JSONDecodeError, IOError): except (json.JSONDecodeError, IOError):
@@ -176,8 +176,8 @@ Attachment: invoice.pdf.exe (double extension trick) #email-forensics #phishing-
data = [c.to_dict() for c in self.cases] data = [c.to_dict() for c in self.cases]
# Write to temp file then rename for atomic-ish write # Write to temp file then rename for atomic-ish write
temp_file = self.data_file.with_suffix(".tmp") temp_file = self.data_file.with_suffix(".tmp")
with open(temp_file, 'w') as f: with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2, ensure_ascii=False)
temp_file.replace(self.data_file) temp_file.replace(self.data_file)
def add_case(self, case: Case): def add_case(self, case: Case):
@@ -225,15 +225,15 @@ class StateManager:
state["evidence_id"] = evidence_id state["evidence_id"] = evidence_id
# Atomic write: write to temp file then rename # Atomic write: write to temp file then rename
temp_file = self.state_file.with_suffix(".tmp") temp_file = self.state_file.with_suffix(".tmp")
with open(temp_file, 'w') as f: with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(state, f) json.dump(state, f, ensure_ascii=False)
temp_file.replace(self.state_file) temp_file.replace(self.state_file)
def get_active(self) -> dict: def get_active(self) -> dict:
if not self.state_file.exists(): if not self.state_file.exists():
return {"case_id": None, "evidence_id": None} return {"case_id": None, "evidence_id": None}
try: try:
with open(self.state_file, 'r') as f: with open(self.state_file, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
except (json.JSONDecodeError, IOError): except (json.JSONDecodeError, IOError):
return {"case_id": None, "evidence_id": None} return {"case_id": None, "evidence_id": None}
@@ -242,7 +242,7 @@ class StateManager:
if not self.settings_file.exists(): if not self.settings_file.exists():
return {"pgp_enabled": True} return {"pgp_enabled": True}
try: try:
with open(self.settings_file, 'r') as f: with open(self.settings_file, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
except (json.JSONDecodeError, IOError): except (json.JSONDecodeError, IOError):
return {"pgp_enabled": True} return {"pgp_enabled": True}
@@ -252,6 +252,6 @@ class StateManager:
settings[key] = value settings[key] = value
# Atomic write: write to temp file then rename # Atomic write: write to temp file then rename
temp_file = self.settings_file.with_suffix(".tmp") temp_file = self.settings_file.with_suffix(".tmp")
with open(temp_file, 'w') as f: with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(settings, f) json.dump(settings, f, ensure_ascii=False)
temp_file.replace(self.settings_file) temp_file.replace(self.settings_file)

View File

@@ -1898,7 +1898,7 @@ class TUI:
line = lines[cursor_line] line = lines[cursor_line]
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 # Auto-wrap to next line if cursor exceeds visible width
if cursor_col >= input_width: if cursor_col >= input_width:
# Always ensure there's a next line to move to # Always ensure there's a next line to move to
@@ -1911,6 +1911,42 @@ class TUI:
if cursor_line >= scroll_offset + input_height: if cursor_line >= scroll_offset + input_height:
scroll_offset = cursor_line - input_height + 1 scroll_offset = cursor_line - input_height + 1
elif ch > 127:
# UTF-8 multi-byte character (umlauts, etc.)
# curses returns the first byte, we need to read the rest
try:
# Try to decode as UTF-8
# For multibyte UTF-8, we need to collect all bytes
bytes_collected = [ch]
# Determine how many bytes we need based on the first byte
if ch >= 0xF0: # 4-byte character
num_bytes = 4
elif ch >= 0xE0: # 3-byte character
num_bytes = 3
elif ch >= 0xC0: # 2-byte character
num_bytes = 2
else:
num_bytes = 1
# Read remaining bytes
for _ in range(num_bytes - 1):
next_ch = win.getch()
bytes_collected.append(next_ch)
# Convert to character
char_bytes = bytes([b & 0xFF for b in bytes_collected])
char = char_bytes.decode('utf-8')
# Insert character at cursor
line = lines[cursor_line]
lines[cursor_line] = line[:cursor_col] + char + line[cursor_col:]
cursor_col += 1
except (UnicodeDecodeError, ValueError):
# If decode fails, ignore the character
pass
def dialog_confirm(self, message): def dialog_confirm(self, message):
curses.curs_set(0) curses.curs_set(0)
h = 5 h = 5
@@ -2643,7 +2679,7 @@ class TUI:
# Write to file # Write to file
try: try:
with open(filepath, 'w') as f: with open(filepath, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines)) f.write('\n'.join(lines))
self.show_message(f"IOCs exported to: {filepath}") self.show_message(f"IOCs exported to: {filepath}")
except Exception as e: except Exception as e: