From f7474a380549bcd1974cf03bb65ced24e37e5933 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Thu, 10 Jul 2025 10:48:08 -0400 Subject: [PATCH] wip --- src/maildir_gtd/actions/archive.py | 5 +- src/maildir_gtd/app.py | 170 ++++++++++++++--------------- src/maildir_gtd/email_viewer.tcss | 14 ++- src/services/himalaya/__init__.py | 4 +- src/services/himalaya/client.py | 41 ++++--- 5 files changed, 117 insertions(+), 117 deletions(-) diff --git a/src/maildir_gtd/actions/archive.py b/src/maildir_gtd/actions/archive.py index 11ddcb2..75bb628 100644 --- a/src/maildir_gtd/actions/archive.py +++ b/src/maildir_gtd/actions/archive.py @@ -1,6 +1,3 @@ -import asyncio -import logging - from textual import work from src.services.himalaya import client as himalaya_client @@ -23,7 +20,7 @@ async def archive_current(app): next_id, next_idx = app.message_store.find_prev_valid_id(current_index) # Archive the message using our Himalaya client module - success = await himalaya_client.archive_message(current_message_id) + success = await himalaya_client.archive_messages([str(current_message_id)]) if success: app.show_status(f"Message {current_message_id} archived.", "success") diff --git a/src/maildir_gtd/app.py b/src/maildir_gtd/app.py index 051a715..accce56 100644 --- a/src/maildir_gtd/app.py +++ b/src/maildir_gtd/app.py @@ -1,12 +1,11 @@ from .message_store import MessageStore from .widgets.ContentContainer import ContentContainer -from .widgets.EnvelopeHeader import EnvelopeHeader from .actions.task import action_create_task from .actions.open import action_open from .actions.delete import delete_current from src.services.taskwarrior import client as taskwarrior_client from src.services.himalaya import client as himalaya_client -from textual.containers import ScrollableContainer, Vertical, Horizontal +from textual.containers import Container, ScrollableContainer, Vertical, Horizontal from textual.timer import Timer from textual.binding import Binding from textual.reactive import reactive, Reactive @@ -16,14 +15,11 @@ from textual.logging import TextualHandler from textual.app import App, ComposeResult, SystemCommand, RenderResult from textual.worker import Worker from textual import work -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__)))) @@ -57,6 +53,7 @@ class EmailViewerApp(App): title = "Maildir GTD Reader" current_message_id: Reactive[int] = reactive(0) current_message_index: Reactive[int] = reactive(0) + highlighted_message_index: Reactive[int] = reactive(0) folder = reactive("INBOX") header_expanded = reactive(False) reload_needed = reactive(True) @@ -147,8 +144,7 @@ class EmailViewerApp(App): 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("#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" @@ -171,8 +167,7 @@ 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 = "↑" if new_value else "↓" - self.query_one("#envelopes_list").border_title = f"1️⃣ Emails { - sort_indicator}" + 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: @@ -183,9 +178,11 @@ class EmailViewerApp(App): self.current_message_index = new_index self._update_list_view_subtitle() - self.query_one("#envelopes_list").index = new_index + self.query_one("#envelopes_list", ListView).index = new_index - def watch_selected_messages(self, old_messages: set[int], new_messages: set[int]) -> None: + def watch_selected_messages( + self, old_messages: set[int], new_messages: set[int] + ) -> None: self._update_list_view_subtitle() def _update_list_view_subtitle(self) -> None: @@ -210,8 +207,7 @@ class EmailViewerApp(App): ) -> None: """Called when the current message ID changes.""" logging.info( - f"Current message ID changed from { - old_message_id} to {new_message_id}" + f"Current message ID changed from {old_message_id} to {new_message_id}" ) if new_message_id == old_message_id: return @@ -273,9 +269,9 @@ class EmailViewerApp(App): 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 = highlighted_index + # message_id = int(current_item["id"]) + # self.current_message_id = message_id + self.highlighted_message_index = highlighted_index @work(exclusive=False) async def fetch_envelopes(self) -> None: @@ -307,7 +303,7 @@ class EmailViewerApp(App): @work(exclusive=False) async def fetch_accounts(self) -> None: - accounts_list = self.query_one("#accounts_list") + accounts_list = self.query_one("#accounts_list", ListView) try: accounts_list.loading = True @@ -333,7 +329,7 @@ class EmailViewerApp(App): @work(exclusive=False) async def fetch_folders(self) -> None: - folders_list = self.query_one("#folders_list") + folders_list = self.query_one("#folders_list", ListView) folders_list.clear() folders_list.append( ListItem(Label("INBOX", classes="folder_name", markup=False)) @@ -370,27 +366,29 @@ class EmailViewerApp(App): if item and item.get("type") == "header": envelopes_list.append( ListItem( - Horizontal( - Label("", classes="checkbox"), # Hidden checkbox for header + Container( + Label("", classes="checkbox"), # Hidden checkbox for header Label( item["label"], classes="group_header", markup=False, ), - classes="envelope_item_row" + classes="envelope_item_row", ) ) ) elif item: # Check if not None # Extract sender and date - sender_name = item.get("from", {}).get("name", item.get("from", {}).get("addr", "Unknown")) + sender_name = item.get("from", {}).get( + "name", item.get("from", {}).get("addr", "Unknown") + ) if not sender_name: sender_name = item.get("from", {}).get("addr", "Unknown") - + # Truncate sender name - max_sender_len = 25 # Adjust as needed + max_sender_len = 25 # Adjust as needed if len(sender_name) > max_sender_len: - sender_name = sender_name[:max_sender_len-3] + "..." + sender_name = sender_name[: max_sender_len - 3] + "..." message_date_str = item.get("date", "") formatted_date = "" @@ -403,25 +401,26 @@ class EmailViewerApp(App): formatted_date = "Invalid Date" list_item = ListItem( - Vertical( - Horizontal( - Label("☐", classes="checkbox"), # Placeholder for checkbox + Container( + Container( + Label("☐", classes="checkbox"), # Placeholder for checkbox Label(sender_name, classes="sender_name"), Label(formatted_date, classes="message_date"), - classes="envelope_header_row" + classes="envelope_header_row", ), - Horizontal( + Container( Label( str(item.get("subject", "")).strip(), classes="email_subject", markup=False, - ) + ), + classes="envelope_subject_row", ), - classes="envelope_item_row" + classes="envelope_item_row", ) ) envelopes_list.append(list_item) - self.refresh_list_view_items() # Initial refresh of item states + self.refresh_list_view_items() # Initial refresh of item states def refresh_list_view_items(self) -> None: """Update the visual state of existing ListItems without clearing the list.""" @@ -429,48 +428,44 @@ class EmailViewerApp(App): for i, list_item in enumerate(envelopes_list.children): if isinstance(list_item, ListItem): item_data = self.message_store.envelopes[i] - + # Find the checkbox label within the ListItem's children - checkbox_label = None - for child in list_item.walk_children(): - if isinstance(child, Label) and "checkbox" in child.classes: - checkbox_label = child - break + # checkbox_label = list_item.query_one(".checkbox", Label) + if item_data and item_data.get("type") != "header": + message_id = int(item_data["id"]) + is_selected = message_id in self.selected_messages or False + list_item.set_class(is_selected, "selection") - if checkbox_label: - if item_data and item_data.get("type") != "header": - message_id = int(item_data["id"]) - is_selected = message_id in self.selected_messages + # if checkbox_label: + # checkbox_label.update("\uf4a7" if is_selected else "\ue640") + # checkbox_label.display = True # Always display checkbox - checkbox_label.update("\uf4a7" if is_selected else "\ue640") - checkbox_label.display = True # Always display checkbox + # list_item.highlighted = is_selected - list_item.highlighted = is_selected + # # Update sender and date labels + # sender_name = item_data.get("from", {}).get("name", item_data.get("from", {}).get("addr", "Unknown")) + # if not sender_name: + # sender_name = item_data.get("from", {}).get("addr", "Unknown") + # max_sender_len = 25 + # if len(sender_name) > max_sender_len: + # sender_name = sender_name[:max_sender_len-3] + "..." + # list_item.query_one(".sender_name", Label).update(sender_name) - # Update sender and date labels - sender_name = item_data.get("from", {}).get("name", item_data.get("from", {}).get("addr", "Unknown")) - if not sender_name: - sender_name = item_data.get("from", {}).get("addr", "Unknown") - max_sender_len = 25 - if len(sender_name) > max_sender_len: - sender_name = sender_name[:max_sender_len-3] + "..." - list_item.query_one(".sender_name", Label).update(sender_name) + # message_date_str = item_data.get("date", "") + # formatted_date = "" + # if message_date_str: + # try: + # dt_object = datetime.fromisoformat(message_date_str) + # formatted_date = dt_object.strftime("%m/%d %H:%M") + # except ValueError: + # formatted_date = "Invalid Date" + # list_item.query_one(".message_date", Label).update(formatted_date) - message_date_str = item_data.get("date", "") - formatted_date = "" - if message_date_str: - try: - dt_object = datetime.fromisoformat(message_date_str) - formatted_date = dt_object.strftime("%m/%d %H:%M") - except ValueError: - formatted_date = "Invalid Date" - list_item.query_one(".message_date", Label).update(formatted_date) - - else: - # For header items, checkbox should be unchecked and visible - checkbox_label.update("\ue640") # Always unchecked for headers - checkbox_label.display = True # Always display checkbox - list_item.highlighted = False # Headers are never highlighted for selection + # else: + # # For header items, checkbox should be unchecked and visible + # checkbox_label.update("\ue640") # Always unchecked for headers + # checkbox_label.display = True # Always display checkbox + # list_item.highlighted = False # Headers are never highlighted for selection # Update total messages count (this is still fine here) # self.total_messages = self.message_store.total_messages @@ -501,7 +496,7 @@ class EmailViewerApp(App): 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() + await content_container.action_toggle_mode() def action_next(self) -> None: if not self.current_message_index >= 0: @@ -543,7 +538,9 @@ class EmailViewerApp(App): highest_archived_id = max(message_ids_to_archive) metadata = self.message_store.get_metadata(highest_archived_id) if metadata: - next_id, _ = self.message_store.find_next_valid_id(metadata["index"]) + next_id, _ = self.message_store.find_next_valid_id( + metadata["index"] + ) if next_id is None: next_id, _ = self.message_store.find_prev_valid_id( metadata["index"] @@ -555,7 +552,7 @@ class EmailViewerApp(App): ) if success: - self.show_status(message) + self.show_status(message or "Success archived") self.selected_messages.clear() else: self.show_status(f"Failed to archive messages: {message}", "error") @@ -575,10 +572,10 @@ class EmailViewerApp(App): next_id, _ = self.message_store.find_prev_valid_id(current_idx) next_id_to_select = next_id - message, success = await himalaya_client.archive_message(current_id) + message, success = await himalaya_client.archive_messages([str(current_id)]) if success: - self.show_status(message) + self.show_status(message or "Archived") else: self.show_status( f"Failed to archive message {current_id}: {message}", "error" @@ -642,32 +639,27 @@ class EmailViewerApp(App): def action_toggle_selection(self) -> None: """Toggle selection for the current message.""" - current_item_data = self.message_store.envelopes[self.current_message_index] + current_item_data = self.message_store.envelopes[self.highlighted_message_index] if current_item_data and current_item_data.get("type") != "header": message_id = int(current_item_data["id"]) + envelopes_list = self.query_one("#envelopes_list", ListView) + current_list_item = envelopes_list.children[self.highlighted_message_index] + checkbox_label = current_list_item.query_one(".checkbox", Label) if message_id in self.selected_messages: self.selected_messages.remove(message_id) + checkbox_label.remove_class("x-list") + checkbox_label.update("\ue640") else: self.selected_messages.add(message_id) - - # Manually update the current ListItem - envelopes_list = self.query_one("#envelopes_list", ListView) - current_list_item = envelopes_list.children[self.current_message_index] - if isinstance(current_list_item, ListItem): - checkbox_label = None - for child in current_list_item.walk_children(): - if isinstance(child, Label) and "checkbox" in child.classes: - checkbox_label = child - break - if checkbox_label: - checkbox_label.update("\uf4a7" if message_id in self.selected_messages else "\ue640") - current_list_item.highlighted = (message_id in self.selected_messages) + checkbox_label.add_class("x-list") + checkbox_label.update("\uf4a7") + self._update_list_view_subtitle() def action_clear_selection(self) -> None: """Clear all selected messages.""" self.selected_messages.clear() - self.refresh_list_view_items() # Refresh all items to uncheck checkboxes + self.refresh_list_view_items() # Refresh all items to uncheck checkboxes self._update_list_view_subtitle() def action_oldest(self) -> None: diff --git a/src/maildir_gtd/email_viewer.tcss b/src/maildir_gtd/email_viewer.tcss index 45662bd..6f91026 100644 --- a/src/maildir_gtd/email_viewer.tcss +++ b/src/maildir_gtd/email_viewer.tcss @@ -16,6 +16,10 @@ } +.envelope-selected { + tint: $accent 20%; +} + #sidebar:focus-within { background: $panel; .list_view:blur { @@ -112,6 +116,7 @@ Markdown { ListItem:even { background: rgb(50, 50, 56); } + & > ListItem { &.-highlight, .selection { color: $block-cursor-blurred-foreground; @@ -124,11 +129,18 @@ Markdown { .envelope_item_row { height: auto; width: 1fr; - Horizontal { + + .envelope_header_row, .envelope_subject_row { height: auto; } } + + +.x-list { + tint: $accent 20%; +} + #open_message_container, #create_task_container { border: panel $border; dock: right; diff --git a/src/services/himalaya/__init__.py b/src/services/himalaya/__init__.py index 63f7053..4a6609f 100644 --- a/src/services/himalaya/__init__.py +++ b/src/services/himalaya/__init__.py @@ -7,7 +7,7 @@ from .client import ( list_accounts, list_folders, delete_message, - archive_message, + archive_messages, get_message_content, ) @@ -16,6 +16,6 @@ __all__ = [ "list_accounts", "list_folders", "delete_message", - "archive_message", + "archive_messages", "get_message_content", ] diff --git a/src/services/himalaya/client.py b/src/services/himalaya/client.py index 9df2a94..2769eb6 100644 --- a/src/services/himalaya/client.py +++ b/src/services/himalaya/client.py @@ -116,28 +116,28 @@ async def delete_message(message_id: int) -> bool: return False -async def archive_message(message_id: int) -> bool: - """ - Archive a message by its ID. +# async def archive_message(message_id: int) -> [str, bool]: +# """ +# Archive a message by its ID. - Args: - message_id: The ID of the message to archive +# Args: +# message_id: The ID of the message to archive - Returns: - True if archiving was successful, False otherwise - """ - try: - process = await asyncio.create_subprocess_shell( - f"himalaya message move Archives {message_id}", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await process.communicate() +# Returns: +# True if archiving was successful, False otherwise +# """ +# try: +# process = await asyncio.create_subprocess_shell( +# f"himalaya message move Archives {message_id}", +# stdout=asyncio.subprocess.PIPE, +# stderr=asyncio.subprocess.PIPE, +# ) +# stdout, stderr = await process.communicate() - return process.returncode == 0 - except Exception as e: - logging.error(f"Exception during message archiving: {e}") - return False +# return [stdout.decode(), process.returncode == 0] +# except Exception as e: +# logging.error(f"Exception during message archiving: {e}") +# return False async def archive_messages(message_ids: List[str]) -> Tuple[Optional[str], bool]: @@ -197,8 +197,7 @@ async def get_message_content(message_id: int) -> Tuple[Optional[str], bool]: content = stdout.decode() return content, True else: - logging.error(f"Error retrieving message content: { - stderr.decode()}") + logging.error(f"Error retrieving message content: {stderr.decode()}") return None, False except Exception as e: logging.error(f"Exception during message content retrieval: {e}")