basically refactored the email viewer

This commit is contained in:
Tim Bendt
2025-05-14 15:11:24 -06:00
parent 5c9ad69309
commit fc57e201a2
20 changed files with 1348 additions and 575 deletions

View File

@@ -1,186 +1,154 @@
import re
import asyncio
import logging
from functools import lru_cache
from datetime import datetime
import re
from textual.widgets import Static, Markdown, Label
from textual.containers import Vertical, Horizontal, ScrollableContainer
from textual import work
from textual.app import ComposeResult
from textual.widgets import Label, Markdown
from textual.containers import ScrollableContainer
from textual.worker import Worker
from apis.himalaya import client as himalaya_client
from widgets.EnvelopeHeader import EnvelopeHeader
class EnvelopeHeader(Vertical):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.subject_label = Label("")
self.from_label = Label("")
self.to_label = Label("")
self.date_label = Label("")
self.cc_label = Label("")
def on_mount(self):
self.styles.height = "auto"
self.mount(self.subject_label)
self.mount(self.from_label)
self.mount(self.to_label)
self.mount(self.cc_label)
self.mount(self.date_label)
def update(self, subject, from_, to, date, cc=None):
self.subject_label.update(f"[b]Subject:[/b] {subject}")
self.from_label.update(f"[b]From:[/b] {from_}")
self.to_label.update(f"[b]To:[/b] {to}")
# Format the date for better readability
if date:
try:
# Try to convert the date string to a datetime object
date_obj = datetime.fromisoformat(date.replace('Z', '+00:00'))
formatted_date = date_obj.strftime("%a, %d %b %Y %H:%M:%S %Z")
self.date_label.update(f"[b]Date:[/b] {formatted_date}")
except (ValueError, TypeError):
# If parsing fails, just use the original date string
self.date_label.update(f"[b]Date:[/b] {date}")
else:
self.date_label.update("[b]Date:[/b] Unknown")
if cc:
self.cc_label.update(f"[b]CC:[/b] {cc}")
self.cc_label.styles.display = "block"
else:
self.cc_label.styles.display = "none"
class ContentContainer(ScrollableContainer):
"""A custom container that can switch between plaintext and markdown rendering."""
can_focus = True
def __init__(self, **kwargs):
super().__init__(**kwargs)
# self.header = EnvelopeHeader(id="envelope_header")
self.content = Markdown("", id="markdown_content")
self.html_content = Static("", id="html_content", markup=False)
self.current_mode = "html" # Default to HTML mode
self.current_content = None
self.current_message_id = None
self.content_worker = None
def compose(self):
yield self.content
yield self.html_content
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.plaintext_mode = True
self.markup_worker = None
self.current_text = ""
self.current_id = None
self.message_cache = dict()
# LRU cache with a max size of 100 messages
def on_mount(self):
# Hide markdown content initially
self.content.styles.display = "none"
self.html_content.styles.display = "block"
def compose(self) -> ComposeResult:
"""Compose the container with a label for plaintext and markdown for rich content."""
yield EnvelopeHeader()
yield Label(id="plaintext_content", markup=False)
yield Markdown(id="markdown_content", classes="hidden")
def update_header(self, subject: str = "", date: str = "", from_: str = "", to: str = "", cc: str = "", bcc: str = "") -> None:
header = self.query_one(EnvelopeHeader)
header.subject = subject
header.date = date
header.from_ = from_
header.to = to
header.cc = cc
header.bcc = bcc
def action_toggle_header(self) -> None:
"""Toggle the visibility of the EnvelopeHeader panel."""
header = self.query_one(EnvelopeHeader)
header.styles.height = "1" if self.header_expanded else "auto"
self.header_expanded = not self.header_expanded
def display_content(self, message_id: int) -> None:
"""Display content for the given message ID."""
self.current_id = message_id
# Show loading state
self.loading = True
# Check if the message is already cached
if message_id in self.message_cache:
self.current_text = self.message_cache[message_id]
plaintext = self.query_one("#plaintext_content", Label)
plaintext.update(self.current_text)
if not self.plaintext_mode:
# We're in markdown mode, so render the markdown
self.render_markdown()
else:
# Hide markdown, show plaintext
plaintext.remove_class("hidden")
self.query_one("#markdown_content").add_class("hidden")
self.loading = False
return self.current_text
async def toggle_mode(self):
"""Toggle between plaintext and HTML viewing modes."""
if self.current_mode == "html":
self.current_mode = "text"
self.html_content.styles.display = "none"
self.content.styles.display = "block"
else:
# Get message body (from cache or fetch new)
self.get_message_body(message_id)
self.current_mode = "html"
self.content.styles.display = "none"
self.html_content.styles.display = "block"
# Reload the content if we have a message ID
if self.current_message_id:
self.display_content(self.current_message_id)
# def update_header(self, subject, from_, to, date, cc=None):
# self.header.update(subject, from_, to, date, cc)
@work(exclusive=True)
async def get_message_body(self, message_id: int) -> str:
"""Fetch the message body from Himalaya CLI."""
async def fetch_message_content(self, message_id: int, format: str):
"""Fetch message content using the Himalaya client module."""
if not message_id:
self.notify("No message ID provided.")
return
try:
# Store the ID of the message we're currently loading
loading_id = message_id
process = await asyncio.create_subprocess_shell(
f"himalaya message read {str(message_id)} -p",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
logging.info(f"stdout: {stdout.decode()[0:50]}...")
# Check if we're still loading the same message or if navigation has moved on
if loading_id != self.current_id:
logging.info(f"Message ID changed during loading. Abandoning load of {loading_id}")
return ""
if process.returncode == 0:
# Process the email content
content = stdout.decode()
# Remove header lines from the beginning of the message
# Headers typically end with a blank line before the message body
lines = content.split('\n')
body_start = 0
# Find the first blank line which typically separates headers from body
for i, line in enumerate(lines):
if line.strip() == '' and i > 0:
# Check if we're past the headers section
# Headers are typically in "Key: Value" format
has_headers = any(': ' in l for l in lines[:i])
if has_headers:
body_start = i + 1
break
# Join the body lines back together
content = '\n'.join(lines[body_start:])
# Apply existing cleanup logic
fixed_text = content.replace("https://urldefense.com/v3/", "")
fixed_text = re.sub(r"atlOrigin.+?\w", "", fixed_text)
logging.info(f"rendering fixedText: {fixed_text[0:50]}")
self.current_text = fixed_text
self.message_cache[message_id] = fixed_text
# Check again if we're still on the same message before updating UI
if loading_id != self.current_id:
logging.info(f"Message ID changed after loading. Abandoning update for {loading_id}")
return fixed_text
# Update the plaintext content
plaintext = self.query_one("#plaintext_content", Label)
plaintext.update(fixed_text)
if not self.plaintext_mode:
# We're in markdown mode, so render the markdown
self.render_markdown()
else:
# Hide markdown, show plaintext
plaintext.remove_class("hidden")
self.query_one("#markdown_content").add_class("hidden")
self.loading = False
return fixed_text
else:
logging.error(f"Error fetching message: {stderr.decode()}")
self.loading = False
return f"Error fetching message content: {stderr.decode()}"
except Exception as e:
logging.error(f"Error fetching message content: {e}")
self.loading = False
return f"Error fetching message content: {e}"
finally:
# Ensure loading state is always reset if this worker completes
# This prevents the loading indicator from getting stuck
if loading_id == self.current_id:
self.loading = False
async def render_markdown(self) -> None:
"""Render the markdown content asynchronously."""
if self.markup_worker:
self.markup_worker.cancel()
markdown = self.query_one("#markdown_content", Markdown)
plaintext = self.query_one("#plaintext_content", Label)
await markdown.update(self.current_text)
# Show markdown, hide plaintext
markdown.remove_class("hidden")
plaintext.add_class("hidden")
async def toggle_mode(self) -> None:
"""Toggle between plaintext and markdown mode."""
self.plaintext_mode = not self.plaintext_mode
if self.plaintext_mode:
# Switch to plaintext
self.query_one("#plaintext_content").remove_class("hidden")
self.query_one("#markdown_content").add_class("hidden")
content, success = await himalaya_client.get_message_content(message_id, format)
if success:
self._update_content(content)
else:
# Switch to markdown
await self.render_markdown()
return self.plaintext_mode
self.notify(f"Failed to fetch content for message ID {message_id}.")
def display_content(self, message_id: int) -> None:
"""Display the content of a message."""
if not message_id:
return
self.current_message_id = message_id
# Cancel any existing content fetch operations
if self.content_worker:
self.content_worker.cancel()
# Fetch content in the current mode
format_type = "text" if self.current_mode == "text" else "html"
self.content_worker = self.fetch_message_content(message_id, format_type)
def _update_content(self, content: str) -> None:
"""Update the content widgets with the fetched content."""
try:
if self.current_mode == "text":
# For text mode, use the Markdown widget
self.content.update(content)
else:
# For HTML mode, use the Static widget with markup
# First, try to extract the body content if it's HTML
body_match = re.search(r'<body[^>]*>(.*?)</body>', content, re.DOTALL | re.IGNORECASE)
if body_match:
content = body_match.group(1)
# Replace some common HTML elements with Textual markup
content = content.replace('<b>', '[b]').replace('</b>', '[/b]')
content = content.replace('<i>', '[i]').replace('</i>', '[/i]')
content = content.replace('<u>', '[u]').replace('</u>', '[/u]')
# Convert links to a readable format
content = re.sub(r'<a href="([^"]+)"[^>]*>([^<]+)</a>', r'[\2](\1)', content)
# Add CSS for better readability
self.html_content.update(content)
except Exception as e:
logging.error(f"Error updating content: {e}")
if self.current_mode == "text":
self.content.update(f"Error displaying content: {e}")
else:
self.html_content.update(f"Error displaying content: {e}")

View File

@@ -2,6 +2,9 @@ from textual.reactive import Reactive
from textual.app import ComposeResult
from textual.widgets import Label
from textual.containers import Horizontal, ScrollableContainer
from datetime import datetime
import re
from datetime import UTC
class EnvelopeHeader(ScrollableContainer):
@@ -55,8 +58,49 @@ class EnvelopeHeader(ScrollableContainer):
# self.query_one("#from").update(from_)
def watch_date(self, date: str) -> None:
"""Watch the date for changes."""
self.query_one("#date").update(date)
"""Watch the date for changes and convert to local timezone."""
if date:
try:
# If date already has timezone info, parse it
if any(x in date for x in ['+', '-', 'Z']):
# Try parsing with timezone info
try:
# Handle ISO format with Z suffix
if 'Z' in date:
parsed_date = datetime.fromisoformat(date.replace('Z', '+00:00'))
else:
parsed_date = datetime.fromisoformat(date)
except ValueError:
# Try another common format
parsed_date = datetime.strptime(date, "%Y-%m-%d %H:%M%z")
else:
# No timezone info, assume UTC
try:
parsed_date = datetime.strptime(date, "%Y-%m-%d %H:%M").replace(tzinfo=UTC)
except ValueError:
# If regular parsing fails, try to extract date components
match = re.search(r"(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})", date)
if match:
date_part, time_part = match.groups()
parsed_date = datetime.strptime(
f"{date_part} {time_part}", "%Y-%m-%d %H:%M"
).replace(tzinfo=UTC)
else:
# If all else fails, just use the original string
self.query_one("#date").update(date)
return
# Convert to local timezone
local_date = parsed_date.astimezone() # Convert to system's local timezone
# Format for display
formatted_date = local_date.strftime("%a %b %d %H:%M (%Z)")
self.query_one("#date").update(formatted_date)
except Exception as e:
# If parsing fails, just display the original date
self.query_one("#date").update(f"{date}")
else:
self.query_one("#date").update("")
# def watch_cc(self, cc: str) -> None:
# """Watch the cc field for changes."""