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,17 +1,15 @@
import re
import sys
import os
from datetime import datetime
from datetime import UTC, datetime
import asyncio
import logging
from typing import Iterable, Optional, List, Dict, Any, Generator, Tuple
from collections import defaultdict
# Add the parent directory to the system path to resolve relative imports
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from textual import work
from textual.worker import Worker
from textual.app import App, ComposeResult, SystemCommand, RenderResult
@@ -23,175 +21,25 @@ from textual.binding import Binding
from textual.timer import Timer
from textual.containers import ScrollableContainer, Vertical, Horizontal
from actions.archive import archive_current
from actions.delete import delete_current
from actions.open import action_open
from actions.task import action_create_task
from widgets.EnvelopeHeader import EnvelopeHeader
from widgets.ContentContainer import ContentContainer
from maildir_gtd.utils import group_envelopes_by_date
# Import our new API modules
from apis.himalaya import client as himalaya_client
from apis.taskwarrior import client as taskwarrior_client
# Updated imports with correct relative paths
from maildir_gtd.actions.archive import archive_current
from maildir_gtd.actions.delete import delete_current
from maildir_gtd.actions.open import action_open
from maildir_gtd.actions.task import action_create_task
from maildir_gtd.widgets.EnvelopeHeader import EnvelopeHeader
from maildir_gtd.widgets.ContentContainer import ContentContainer
from maildir_gtd.message_store import MessageStore
logging.basicConfig(
level="NOTSET",
handlers=[TextualHandler()],
)
class MessageStore:
"""Centralized store for email message data with efficient lookups and updates."""
def __init__(self):
self.envelopes: List[Dict[str, Any]] = [] # Full envelope data including headers
self.by_id: Dict[int, Dict[str, Any]] = {} # Map message IDs to envelope data
self.id_to_index: Dict[int, int] = {} # Map message IDs to list indices
self.total_messages = 0
self.sort_ascending = True
def clear(self) -> None:
"""Clear all data structures."""
self.envelopes = []
self.by_id = {}
self.id_to_index = {}
self.total_messages = 0
def load(self, raw_envelopes: List[Dict[str, Any]], sort_ascending: bool = True) -> None:
"""Load envelopes from raw data and set up the data structures."""
self.clear()
self.sort_ascending = sort_ascending
# Sort the envelopes by date
sorted_envelopes = sorted(
raw_envelopes,
key=lambda x: x["date"],
reverse=not sort_ascending,
)
# Group them by date for display
self.envelopes = group_envelopes_by_date(sorted_envelopes)
# Build lookup dictionaries
for idx, envelope in enumerate(self.envelopes):
if "id" in envelope and envelope.get("type") != "header":
msg_id = int(envelope["id"])
self.by_id[msg_id] = envelope
self.id_to_index[msg_id] = idx
# Count actual messages (excluding headers)
self.total_messages = len(self.by_id)
def get_by_id(self, msg_id: int) -> Optional[Dict[str, Any]]:
"""Get an envelope by its ID."""
return self.by_id.get(msg_id)
def get_index_by_id(self, msg_id: int) -> Optional[int]:
"""Get the list index for a message ID."""
return self.id_to_index.get(msg_id)
def get_metadata(self, msg_id: int) -> Dict[str, Any]:
"""Get essential metadata for a message."""
envelope = self.get_by_id(msg_id)
if not envelope:
return {}
return {
"subject": envelope.get("subject", ""),
"from": envelope.get("from", {}),
"to": envelope.get("to", {}),
"date": envelope.get("date", ""),
"cc": envelope.get("cc", {}),
"index": self.get_index_by_id(msg_id),
}
def remove(self, msg_id: int) -> None:
"""Remove a message from all data structures."""
# Get the index first before we remove from dictionaries
idx = self.id_to_index.get(msg_id)
# Remove from dictionaries
self.by_id.pop(msg_id, None)
self.id_to_index.pop(msg_id, None)
# Remove from list if we found an index
if idx is not None:
self.envelopes[idx] = None # Mark as None rather than removing to maintain indices
# Update total count
self.total_messages = len(self.by_id)
def find_next_valid_id(self, current_idx: int) -> Tuple[Optional[int], Optional[int]]:
"""Find the next valid message ID and its index after the current index."""
# Look forward first
try:
# Optimized with better short-circuit logic
# Only check type if env exists and has an ID
idx, envelope = next(
(i, env) for i, env in enumerate(self.envelopes[current_idx + 1:], current_idx + 1)
if env and "id" in env and env.get("type") != "header"
)
return int(envelope["id"]), idx
except StopIteration:
# If not found in forward direction, look from beginning
try:
idx, envelope = next(
(i, env) for i, env in enumerate(self.envelopes[:current_idx])
if env and "id" in env and env.get("type") != "header"
)
return int(envelope["id"]), idx
except StopIteration:
return None, None
def find_prev_valid_id(self, current_idx: int) -> Tuple[Optional[int], Optional[int]]:
"""Find the previous valid message ID and its index before the current index."""
# Look backward first
try:
# Create a range of indices in reverse order
backward_range = range(current_idx - 1, -1, -1) # No need to convert to list
# Using optimized short-circuit evaluation
idx, envelope = next(
(i, self.envelopes[i]) for i in backward_range
if self.envelopes[i] and "id" in self.envelopes[i] and self.envelopes[i].get("type") != "header"
)
return int(envelope["id"]), idx
except StopIteration:
# If not found, look from end downward to current
try:
backward_range = range(len(self.envelopes) - 1, current_idx, -1) # No need to convert to list
idx, envelope = next(
(i, self.envelopes[i]) for i in backward_range
if self.envelopes[i] and "id" in self.envelopes[i] and self.envelopes[i].get("type") != "header"
)
return int(envelope["id"]), idx
except StopIteration:
return None, None
def get_oldest_id(self) -> Optional[int]:
"""Get the ID of the oldest message."""
if not self.envelopes:
return None
for envelope in self.envelopes:
if envelope and "id" in envelope and envelope.get("type") != "header":
return int(envelope["id"])
return None
def get_newest_id(self) -> Optional[int]:
"""Get the ID of the newest message."""
if not self.envelopes:
return None
for envelope in reversed(self.envelopes):
if envelope and "id" in envelope and envelope.get("type") != "header":
return int(envelope["id"])
return None
def get_valid_envelopes(self) -> Generator[Dict[str, Any], None, None]:
"""Get all valid (non-header) envelopes."""
return (envelope for envelope in self.envelopes
if envelope and "id" in envelope and envelope.get("type") != "header")
class StatusTitle(Static):
total_messages: Reactive[int] = reactive(0)
current_message_index: Reactive[int] = reactive(0)
@@ -293,11 +141,11 @@ class EmailViewerApp(App):
self.theme = "monokai"
self.title = "MaildirGTD"
self.query_one("#main_content").border_title = self.status_title
sort_indicator = "\u2191" if self.sort_order_ascending else "\u2193"
self.query_one("#envelopes_list").border_title = f"\[1] Emails {sort_indicator}"
self.query_one("#accounts_list").border_title = "\[2] Accounts"
sort_indicator = "" if self.sort_order_ascending else ""
self.query_one("#envelopes_list").border_title = f"1 Emails {sort_indicator}"
self.query_one("#accounts_list").border_title = "2 Accounts"
self.query_one("#folders_list").border_title = "\[3] Folders"
self.query_one("#folders_list").border_title = "3 Folders"
self.fetch_accounts()
self.fetch_folders()
@@ -314,8 +162,8 @@ class EmailViewerApp(App):
def watch_sort_order_ascending(self, old_value: bool, new_value: bool) -> None:
"""Update the border title of the envelopes list when the sort order changes."""
sort_indicator = "\u2191" if new_value else "\u2193"
self.query_one("#envelopes_list").border_title = f"\[1] Emails {sort_indicator}"
sort_indicator = "" if new_value else ""
self.query_one("#envelopes_list").border_title = f"1 Emails {sort_indicator}"
def watch_current_message_index(self, old_index: int, new_index: int) -> None:
if new_index < 0:
@@ -353,21 +201,19 @@ class EmailViewerApp(App):
metadata = self.message_store.get_metadata(new_message_id)
if metadata:
message_date = re.sub(r"[\+\-]\d\d:\d\d", "", metadata["date"])
message_date = datetime.strptime(message_date, "%Y-%m-%d %H:%M").strftime(
"%a %b %d %H:%M"
)
# Pass the complete date string with timezone information
message_date = metadata["date"]
if self.current_message_index != metadata["index"]:
self.current_message_index = metadata["index"]
content_container.update_header(
subject=metadata.get("subject", "").strip(),
from_=metadata["from"].get("addr", ""),
to=metadata["to"].get("addr", ""),
date=message_date,
cc=metadata["cc"].get("addr", "") if "cc" in metadata else "",
)
# content_container.update_header(
# subject=metadata.get("subject", "").strip(),
# from_=metadata["from"].get("addr", ""),
# to=metadata["to"].get("addr", ""),
# date=message_date,
# cc=metadata["cc"].get("addr", "") if "cc" in metadata else "",
# )
list_view = self.query_one("#envelopes_list")
if list_view.index != metadata["index"]:
@@ -391,47 +237,22 @@ class EmailViewerApp(App):
msglist = self.query_one("#envelopes_list")
try:
msglist.loading = True
process = await asyncio.create_subprocess_shell(
"himalaya envelope list -o json -s 9999",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
logging.info(f"stdout: {stdout.decode()[0:50]}")
if process.returncode == 0:
import json
envelopes = json.loads(stdout.decode())
if envelopes:
self.reload_needed = False
self.message_store.load(envelopes, self.sort_order_ascending)
self.total_messages = self.message_store.total_messages
msglist.clear()
# Use the Himalaya client to fetch envelopes
envelopes, success = await himalaya_client.list_envelopes()
for item in self.message_store.envelopes:
if item.get("type") == "header":
msglist.append(
ListItem(
Label(
item["label"],
classes="group_header",
markup=False,
)
)
)
else:
msglist.append(
ListItem(
Label(
str(item["subject"]).strip(),
classes="email_subject",
markup=False,
)
)
)
msglist.index = self.current_message_index
else:
self.show_status("Failed to fetch any envelopes.", "error")
if success and envelopes:
self.reload_needed = False
self.message_store.load(envelopes, self.sort_order_ascending)
self.total_messages = self.message_store.total_messages
# Use the centralized refresh method to update the ListView
self.refresh_list_view()
# Restore the current index
msglist.index = self.current_message_index
else:
self.show_status("Failed to fetch envelopes.", "error")
except Exception as e:
self.show_status(f"Error fetching message list: {e}", "error")
finally:
@@ -442,27 +263,22 @@ class EmailViewerApp(App):
accounts_list = self.query_one("#accounts_list")
try:
accounts_list.loading = True
process = await asyncio.create_subprocess_shell(
"himalaya account list -o json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
logging.info(f"stdout: {stdout.decode()[0:50]}")
if process.returncode == 0:
import json
accounts = json.loads(stdout.decode())
if accounts:
for account in accounts:
item = ListItem(
Label(
str(account["name"]).strip(),
classes="account_name",
markup=False,
)
# Use the Himalaya client to fetch accounts
accounts, success = await himalaya_client.list_accounts()
if success and accounts:
for account in accounts:
item = ListItem(
Label(
str(account["name"]).strip(),
classes="account_name",
markup=False,
)
accounts_list.append(item)
)
accounts_list.append(item)
else:
self.show_status("Failed to fetch accounts.", "error")
except Exception as e:
self.show_status(f"Error fetching account list: {e}", "error")
finally:
@@ -477,32 +293,57 @@ class EmailViewerApp(App):
)
try:
folders_list.loading = True
process = await asyncio.create_subprocess_shell(
"himalaya folder list -o json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
logging.info(f"stdout: {stdout.decode()[0:50]}")
if process.returncode == 0:
import json
folders = json.loads(stdout.decode())
if folders:
for folder in folders:
item = ListItem(
Label(
str(folder["name"]).strip(),
classes="folder_name",
markup=False,
)
# Use the Himalaya client to fetch folders
folders, success = await himalaya_client.list_folders()
if success and folders:
for folder in folders:
item = ListItem(
Label(
str(folder["name"]).strip(),
classes="folder_name",
markup=False,
)
folders_list.append(item)
)
folders_list.append(item)
else:
self.show_status("Failed to fetch folders.", "error")
except Exception as e:
self.show_status(f"Error fetching folder list: {e}", "error")
finally:
folders_list.loading = False
def refresh_list_view(self) -> None:
"""Refresh the ListView to ensure it matches the MessageStore exactly."""
envelopes_list = self.query_one("#envelopes_list")
envelopes_list.clear()
for item in self.message_store.envelopes:
if item and item.get("type") == "header":
envelopes_list.append(
ListItem(
Label(
item["label"],
classes="group_header",
markup=False,
)
)
)
elif item: # Check if not None
envelopes_list.append(
ListItem(
Label(
str(item.get("subject", "")).strip(),
classes="email_subject",
markup=False,
)
)
)
# Update total messages count
self.total_messages = self.message_store.total_messages
def show_message(self, message_id: int, new_index=None) -> None:
if new_index:
self.current_message_index = new_index
@@ -553,16 +394,16 @@ class EmailViewerApp(App):
self.fetch_envelopes() if self.reload_needed else None
async def action_delete(self) -> None:
message_id_to_delete = self.current_message_id
self.message_store.remove(message_id_to_delete)
self.total_messages = self.message_store.total_messages
delete_current(self)
"""Delete the current message and update UI consistently."""
# Call the delete_current function which uses our Himalaya client module
worker = delete_current(self)
await worker.wait()
async def action_archive(self) -> None:
message_id_to_archive = self.current_message_id
self.message_store.remove(message_id_to_archive)
self.total_messages = self.message_store.total_messages
archive_current(self)
"""Archive the current message and update UI consistently."""
# Call the archive_current function which uses our Himalaya client module
worker = archive_current(self)
await worker.wait()
def action_open(self) -> None:
action_open(self)