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