basically refactored the email viewer
This commit is contained in:
@@ -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}")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user