import re import sys import os 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__)))) from textual import work from textual.worker import Worker from textual.app import App, ComposeResult, SystemCommand, RenderResult from textual.logging import TextualHandler from textual.screen import Screen from textual.widgets import Footer, Static, Label, Markdown, ListView, ListItem from textual.reactive import reactive, Reactive from textual.binding import Binding from textual.timer import Timer from textual.containers import ScrollableContainer, Vertical, Horizontal # 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 StatusTitle(Static): total_messages: Reactive[int] = reactive(0) current_message_index: Reactive[int] = reactive(0) current_message_id: Reactive[int] = reactive(1) folder: Reactive[str] = reactive("INBOX") def render(self) -> RenderResult: return f"{self.folder} | ID: {self.current_message_id} | [b]{self.current_message_index}[/b]/{self.total_messages}" class EmailViewerApp(App): """A simple email viewer app using the Himalaya CLI.""" CSS_PATH = "email_viewer.tcss" title = "Maildir GTD Reader" current_message_id: Reactive[int] = reactive(0) current_message_index: Reactive[int] = reactive(0) folder = reactive("INBOX") header_expanded = reactive(False) reload_needed = reactive(True) message_store = MessageStore() oldest_id: Reactive[int] = reactive(0) newest_id: Reactive[int] = reactive(0) msg_worker: Worker | None = None total_messages: Reactive[int] = reactive(0) status_title = reactive("Message View") sort_order_ascending: Reactive[bool] = reactive(True) def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: yield from super().get_system_commands(screen) yield SystemCommand("Next Message", "Navigate to Next ID", self.action_next) yield SystemCommand( "Previous Message", "Navigate to Previous ID", self.action_previous ) yield SystemCommand( "Delete Message", "Delete the current message", self.action_delete ) yield SystemCommand( "Archive Message", "Archive the current message", self.action_archive ) yield SystemCommand( "Open Message", "Open a specific message by ID", self.action_open ) yield SystemCommand( "Create Task", "Create a task using the task CLI", self.action_create_task ) yield SystemCommand( "Oldest Message", "Show the oldest message", self.action_oldest ) yield SystemCommand( "Newest Message", "Show the newest message", self.action_newest ) yield SystemCommand("Reload", "Reload the message list", self.fetch_envelopes) BINDINGS = [ Binding("j", "next", "Next message"), Binding("k", "previous", "Previous message"), Binding("#", "delete", "Delete message"), Binding("e", "archive", "Archive message"), Binding("o", "open", "Open message", show=False), Binding("q", "quit", "Quit application"), Binding("h", "toggle_header", "Toggle Envelope Header"), Binding("t", "create_task", "Create Task"), Binding("%", "reload", "Reload message list"), Binding("1", "focus_1", "Focus Accounts Panel"), Binding("2", "focus_2", "Focus Folders Panel"), Binding("3", "focus_3", "Focus Envelopes Panel"), Binding("m", "toggle_mode", "Toggle Content Mode"), ] BINDINGS.extend( [ Binding("space", "scroll_page_down", "Scroll page down"), Binding("b", "scroll_page_up", "Scroll page up"), Binding("s", "toggle_sort_order", "Toggle Sort Order"), ] ) def compose(self) -> ComposeResult: yield Horizontal( Vertical( ListView( ListItem(Label("All emails...")), id="envelopes_list", classes="list_view", initial_index=0, ), ListView(id="accounts_list", classes="list_view"), ListView(id="folders_list", classes="list_view"), id="sidebar", ), ContentContainer(id="main_content"), id="outer-wrapper", ) yield Footer() async def on_mount(self) -> None: self.alert_timer: Timer | None = None # Timer to throttle alerts self.theme = "monokai" self.title = "MaildirGTD" self.query_one("#main_content").border_title = self.status_title 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.fetch_accounts() self.fetch_folders() worker = self.fetch_envelopes() await worker.wait() self.query_one("#envelopes_list").focus() self.action_oldest() def compute_status_title(self) -> None: return f"✉️ Message ID: {self.current_message_id} " def watch_status_title(self, old_status_title: str, new_status_title: str) -> None: self.query_one(ContentContainer).border_title = new_status_title 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 = "↑" 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: new_index = 0 self.current_message_index = new_index if new_index > self.total_messages: new_index = self.total_messages self.current_message_index = new_index self.query_one( "#envelopes_list" ).border_subtitle = f"[b]{new_index}[/b]/{self.total_messages}" self.query_one("#envelopes_list").index = new_index def watch_reload_needed( self, old_reload_needed: bool, new_reload_needed: bool ) -> None: logging.info(f"Reload needed: {new_reload_needed}") if not old_reload_needed and new_reload_needed: self.fetch_envelopes() def watch_current_message_id( self, old_message_id: int, new_message_id: int ) -> None: """Called when the current message ID changes.""" logging.info( f"Current message ID changed from {old_message_id} to {new_message_id}" ) if new_message_id == old_message_id: return self.msg_worker.cancel() if self.msg_worker else None logging.info(f"new_message_id: {new_message_id}, type: {type(new_message_id)}") content_container = self.query_one(ContentContainer) content_container.display_content(new_message_id) metadata = self.message_store.get_metadata(new_message_id) if metadata: # 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 "", # ) list_view = self.query_one("#envelopes_list") if list_view.index != metadata["index"]: list_view.index = metadata["index"] else: logging.warning(f"Message ID {new_message_id} not found in metadata.") def on_list_view_selected(self, event: ListView.Selected) -> None: """Called when an item in the list view is selected.""" current_item = self.message_store.envelopes[event.list_view.index] if current_item is None or current_item.get("type") == "header": return message_id = int(current_item["id"]) self.current_message_id = message_id self.current_message_index = event.list_view.index @work(exclusive=False) async def fetch_envelopes(self) -> None: msglist = self.query_one("#envelopes_list") try: msglist.loading = True # Use the Himalaya client to fetch envelopes envelopes, success = await himalaya_client.list_envelopes() 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: msglist.loading = False @work(exclusive=False) async def fetch_accounts(self) -> None: accounts_list = self.query_one("#accounts_list") try: accounts_list.loading = True # 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) else: self.show_status("Failed to fetch accounts.", "error") except Exception as e: self.show_status(f"Error fetching account list: {e}", "error") finally: accounts_list.loading = False @work(exclusive=False) async def fetch_folders(self) -> None: folders_list = self.query_one("#folders_list") folders_list.clear() folders_list.append( ListItem(Label("INBOX", classes="folder_name", markup=False)) ) try: folders_list.loading = True # 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) 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 self.current_message_id = message_id def show_status(self, message: str, severity: str = "information") -> None: """Display a status message using the built-in notify function.""" self.notify( message, title="Status", severity=severity, timeout=2.6, markup=True ) async def action_toggle_sort_order(self) -> None: """Toggle the sort order of the envelope list.""" self.sort_order_ascending = not self.sort_order_ascending worker = self.fetch_envelopes() await worker.wait() if self.sort_order_ascending: self.action_oldest() else: self.action_newest() async def action_toggle_mode(self) -> None: """Toggle the content mode between plaintext and markdown.""" content_container = self.query_one(ContentContainer) await content_container.toggle_mode() def action_next(self) -> None: if not self.current_message_index >= 0: return next_id, next_idx = self.message_store.find_next_valid_id(self.current_message_index) if next_id is not None and next_idx is not None: self.current_message_id = next_id self.current_message_index = next_idx self.fetch_envelopes() if self.reload_needed else None def action_previous(self) -> None: if not self.current_message_index >= 0: return prev_id, prev_idx = self.message_store.find_prev_valid_id(self.current_message_index) if prev_id is not None and prev_idx is not None: self.current_message_id = prev_id self.current_message_index = prev_idx self.fetch_envelopes() if self.reload_needed else None async def action_delete(self) -> None: """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: """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) def action_create_task(self) -> None: action_create_task(self) def action_scroll_down(self) -> None: """Scroll the main content down.""" self.query_one("#main_content").scroll_down() def action_scroll_up(self) -> None: """Scroll the main content up.""" self.query_one("#main_content").scroll_up() def action_scroll_page_down(self) -> None: """Scroll the main content down by a page.""" self.query_one("#main_content").scroll_page_down() def action_scroll_page_up(self) -> None: """Scroll the main content up by a page.""" self.query_one("#main_content").scroll_page_up() def action_quit(self) -> None: """Quit the application.""" self.exit() def action_oldest(self) -> None: self.fetch_envelopes() if self.reload_needed else None self.show_message(self.message_store.get_oldest_id()) def action_newest(self) -> None: self.fetch_envelopes() if self.reload_needed else None self.show_message(self.message_store.get_newest_id()) def action_focus_1(self) -> None: self.query_one("#envelopes_list").focus() def action_focus_2(self) -> None: self.query_one("#accounts_list").focus() def action_focus_3(self) -> None: self.query_one("#folders_list").focus() if __name__ == "__main__": app = EmailViewerApp() app.run()