This commit is contained in:
Tim Bendt
2025-07-10 10:48:08 -04:00
parent 7cc1c30356
commit f7474a3805
5 changed files with 117 additions and 117 deletions

View File

@@ -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")

View File

@@ -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:

View File

@@ -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;

View File

@@ -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",
]

View File

@@ -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}")