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

7
apis/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""
APIs package for the GTD Terminal Tools project.
This package contains modules for interacting with various external services like:
- Himalaya email client
- Taskwarrior task manager
"""

21
apis/himalaya/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
"""
Himalaya API module for interacting with the Himalaya email client.
"""
from apis.himalaya.client import (
list_envelopes,
list_accounts,
list_folders,
delete_message,
archive_message,
get_message_content,
)
__all__ = [
"list_envelopes",
"list_accounts",
"list_folders",
"delete_message",
"archive_message",
"get_message_content",
]

169
apis/himalaya/client.py Normal file
View File

@@ -0,0 +1,169 @@
import asyncio
import json
import logging
from typing import Tuple, List, Dict, Any, Optional, Union
async def list_envelopes(limit: int = 9999) -> Tuple[List[Dict[str, Any]], bool]:
"""
Retrieve a list of email envelopes using the Himalaya CLI.
Args:
limit: Maximum number of envelopes to retrieve
Returns:
Tuple containing:
- List of envelope dictionaries
- Success status (True if operation was successful)
"""
try:
process = await asyncio.create_subprocess_shell(
f"himalaya envelope list -o json -s {limit}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
envelopes = json.loads(stdout.decode())
return envelopes, True
else:
logging.error(f"Error listing envelopes: {stderr.decode()}")
return [], False
except Exception as e:
logging.error(f"Exception during envelope listing: {e}")
return [], False
async def list_accounts() -> Tuple[List[Dict[str, Any]], bool]:
"""
Retrieve a list of accounts configured in Himalaya.
Returns:
Tuple containing:
- List of account dictionaries
- Success status (True if operation was successful)
"""
try:
process = await asyncio.create_subprocess_shell(
"himalaya account list -o json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
accounts = json.loads(stdout.decode())
return accounts, True
else:
logging.error(f"Error listing accounts: {stderr.decode()}")
return [], False
except Exception as e:
logging.error(f"Exception during account listing: {e}")
return [], False
async def list_folders() -> Tuple[List[Dict[str, Any]], bool]:
"""
Retrieve a list of folders available in Himalaya.
Returns:
Tuple containing:
- List of folder dictionaries
- Success status (True if operation was successful)
"""
try:
process = await asyncio.create_subprocess_shell(
"himalaya folder list -o json",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
folders = json.loads(stdout.decode())
return folders, True
else:
logging.error(f"Error listing folders: {stderr.decode()}")
return [], False
except Exception as e:
logging.error(f"Exception during folder listing: {e}")
return [], False
async def delete_message(message_id: int) -> bool:
"""
Delete a message by its ID.
Args:
message_id: The ID of the message to delete
Returns:
True if deletion was successful, False otherwise
"""
try:
process = await asyncio.create_subprocess_shell(
f"himalaya message delete {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 deletion: {e}")
return False
async def archive_message(message_id: int) -> bool:
"""
Archive a message by its ID.
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 archive {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
async def get_message_content(message_id: int, format: str = "html") -> Tuple[Optional[str], bool]:
"""
Retrieve the content of a message by its ID.
Args:
message_id: The ID of the message to retrieve
format: The desired format of the message content ("html" or "text")
Returns:
Tuple containing:
- Message content (or None if retrieval failed)
- Success status (True if operation was successful)
"""
try:
cmd = f"himalaya message read {message_id}"
if format == "text":
cmd += " -t"
process = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
content = stdout.decode()
return content, True
else:
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}")
return None, False

View File

@@ -59,6 +59,6 @@ def get_access_token(scopes):
f.write(cache.serialize())
access_token = token_response['access_token']
headers = {'Authorization': f'Bearer {access_token}', 'Prefer': 'outlook.body-content-type="text"'}
headers = {'Authorization': f'Bearer {access_token}', 'Prefer': 'outlook.body-content-type="text",IdType="ImmutableId"'}
return access_token, headers

View File

@@ -0,0 +1,17 @@
"""
Taskwarrior API module for interacting with the Taskwarrior command-line task manager.
"""
from apis.taskwarrior.client import (
create_task,
list_tasks,
complete_task,
delete_task,
)
__all__ = [
"create_task",
"list_tasks",
"complete_task",
"delete_task",
]

146
apis/taskwarrior/client.py Normal file
View File

@@ -0,0 +1,146 @@
import asyncio
import json
import logging
from typing import Tuple, List, Dict, Any, Optional, Union
async def create_task(task_description: str, tags: List[str] = None, project: str = None,
due: str = None, priority: str = None) -> Tuple[bool, Optional[str]]:
"""
Create a new task using the Taskwarrior CLI.
Args:
task_description: Description of the task
tags: List of tags to apply to the task
project: Project to which the task belongs
due: Due date in the format that Taskwarrior accepts
priority: Priority of the task (H, M, L)
Returns:
Tuple containing:
- Success status (True if operation was successful)
- Task ID or error message
"""
try:
cmd = ["task", "add"]
# Add project if specified
if project:
cmd.append(f"project:{project}")
# Add tags if specified
if tags:
for tag in tags:
cmd.append(f"+{tag}")
# Add due date if specified
if due:
cmd.append(f"due:{due}")
# Add priority if specified
if priority and priority in ["H", "M", "L"]:
cmd.append(f"priority:{priority}")
# Add task description
cmd.append(task_description)
# Convert command list to string
cmd_str = " ".join(cmd)
process = await asyncio.create_subprocess_shell(
cmd_str,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
return True, stdout.decode().strip()
else:
error_msg = stderr.decode().strip()
logging.error(f"Error creating task: {error_msg}")
return False, error_msg
except Exception as e:
logging.error(f"Exception during task creation: {e}")
return False, str(e)
async def list_tasks(filter_str: str = "") -> Tuple[List[Dict[str, Any]], bool]:
"""
List tasks from Taskwarrior.
Args:
filter_str: Optional filter string to pass to Taskwarrior
Returns:
Tuple containing:
- List of task dictionaries
- Success status (True if operation was successful)
"""
try:
cmd = f"task {filter_str} export"
process = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
tasks = json.loads(stdout.decode())
return tasks, True
else:
logging.error(f"Error listing tasks: {stderr.decode()}")
return [], False
except Exception as e:
logging.error(f"Exception during task listing: {e}")
return [], False
async def complete_task(task_id: str) -> bool:
"""
Mark a task as completed.
Args:
task_id: ID of the task to complete
Returns:
True if task was completed successfully, False otherwise
"""
try:
cmd = f"echo 'yes' | task {task_id} done"
process = await asyncio.create_subprocess_shell(
cmd,
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 task completion: {e}")
return False
async def delete_task(task_id: str) -> bool:
"""
Delete a task.
Args:
task_id: ID of the task to delete
Returns:
True if task was deleted successfully, False otherwise
"""
try:
cmd = f"echo 'yes' | task {task_id} delete"
process = await asyncio.create_subprocess_shell(
cmd,
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 task deletion: {e}")
return False

315
benchmark_list_update.py Executable file
View File

@@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""
Benchmark script to compare two approaches for updating envelopes list in maildir_gtd.
This script compares:
1. Using .pop() to remove items from ListView
2. Using refresh_list_view() to rebuild the entire ListView
It tests with different numbers of envelopes (100, 1000, 2000) and measures:
- Time to remove a single item
- Time to remove multiple items in sequence
- Memory usage
"""
import sys
import os
import time
import random
import gc
import tracemalloc
from datetime import datetime, timedelta, UTC
from typing import List, Dict, Any, Callable, Tuple
import json
# Add parent directory to path so we can import modules correctly
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Import required classes and functions
from textual.widgets import ListView, ListItem, Label
from textual.app import App, ComposeResult
from textual.containers import Vertical
# Import our application's modules
from maildir_gtd.app import MessageStore
from maildir_gtd.utils import group_envelopes_by_date
# Mock class to simulate the ListView behavior
class MockListView:
def __init__(self):
self.items = []
self.index = 0
def append(self, item):
self.items.append(item)
def pop(self, idx=None):
if idx is None:
return self.items.pop()
return self.items.pop(idx)
def clear(self):
self.items = []
def __len__(self):
return len(self.items)
# Helper functions to generate test data
def generate_envelope(idx: int) -> Dict[str, Any]:
"""Generate a synthetic envelope with predictable data."""
now = datetime.now(UTC)
# Distribute dates over the last 60 days to create realistic grouping
date = now - timedelta(days=random.randint(0, 60),
hours=random.randint(0, 23),
minutes=random.randint(0, 59))
return {
"id": str(idx),
"subject": f"Test Subject {idx}",
"from": {"addr": f"sender{idx}@example.com"},
"to": {"addr": f"recipient{idx}@example.com"},
"date": date.strftime("%Y-%m-%d %H:%M"),
"cc": {},
"type": "message"
}
def generate_test_envelopes(count: int) -> List[Dict[str, Any]]:
"""Generate a specified number of test envelopes."""
return [generate_envelope(i) for i in range(1, count + 1)]
# Benchmark functions
def benchmark_pop_approach(store: MessageStore, list_view: MockListView, indices_to_remove: List[int]) -> float:
"""Benchmark the .pop() approach."""
start_time = time.time()
for idx in sorted(indices_to_remove, reverse=True): # Remove from highest to lowest to avoid index shifting issues
msg_id = int(store.envelopes[idx]["id"])
store.remove(msg_id)
list_view.pop(idx)
end_time = time.time()
return end_time - start_time
def benchmark_refresh_approach(store: MessageStore, list_view: MockListView, indices_to_remove: List[int]) -> float:
"""Benchmark the refresh_list_view approach."""
start_time = time.time()
for idx in indices_to_remove:
msg_id = int(store.envelopes[idx]["id"])
store.remove(msg_id)
# Simulate refresh_list_view by clearing and rebuilding the list
list_view.clear()
for item in store.envelopes:
if item and item.get("type") == "header":
list_view.append(f"Header: {item['label']}")
elif item: # Check if not None
list_view.append(f"Email: {item.get('subject', '')}")
end_time = time.time()
return end_time - start_time
def run_memory_benchmark(func, *args):
"""Run a function with memory tracking."""
tracemalloc.start()
result = func(*args)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return result, current, peak
def run_benchmark(envelope_count: int, num_operations: int = 10):
"""Run benchmarks for a specific number of envelopes."""
print(f"\n{'=' * 50}")
print(f"Running benchmark with {envelope_count} envelopes")
print(f"{'=' * 50}")
# Generate test data
envelopes = generate_test_envelopes(envelope_count)
# Set up for pop approach
pop_store = MessageStore()
pop_store.load(envelopes.copy())
pop_list_view = MockListView()
# Build initial list view
for item in pop_store.envelopes:
if item and item.get("type") == "header":
pop_list_view.append(f"Header: {item['label']}")
elif item:
pop_list_view.append(f"Email: {item.get('subject', '')}")
# Set up for refresh approach
refresh_store = MessageStore()
refresh_store.load(envelopes.copy())
refresh_list_view = MockListView()
# Build initial list view
for item in refresh_store.envelopes:
if item and item.get("type") == "header":
refresh_list_view.append(f"Header: {item['label']}")
elif item:
refresh_list_view.append(f"Email: {item.get('subject', '')}")
# Generate random indices to remove (ensure they're valid message indices, not headers)
valid_indices = []
for idx, item in enumerate(pop_store.envelopes):
if item and item.get("type") != "header" and item is not None:
valid_indices.append(idx)
if len(valid_indices) < num_operations:
num_operations = len(valid_indices)
print(f"Warning: Only {num_operations} valid messages available for removal")
indices_to_remove = random.sample(valid_indices, num_operations)
# Single operation benchmark
print("\n🔹 Single operation benchmark (removing 1 item):")
# Pop approach - single operation
gc.collect() # Ensure clean state
single_pop_time, pop_current, pop_peak = run_memory_benchmark(
benchmark_pop_approach, pop_store, pop_list_view, [indices_to_remove[0]]
)
print(f" Pop approach: {single_pop_time*1000:.2f} ms (Memory - Current: {pop_current/1024:.1f} KB, Peak: {pop_peak/1024:.1f} KB)")
# Refresh approach - single operation
gc.collect() # Ensure clean state
single_refresh_time, refresh_current, refresh_peak = run_memory_benchmark(
benchmark_refresh_approach, refresh_store, refresh_list_view, [indices_to_remove[0]]
)
print(f" Refresh approach: {single_refresh_time*1000:.2f} ms (Memory - Current: {refresh_current/1024:.1f} KB, Peak: {refresh_peak/1024:.1f} KB)")
# Determine which is better for single operation
if single_pop_time < single_refresh_time:
print(f" 🥇 Pop is {single_refresh_time/single_pop_time:.1f}x faster for single operation")
else:
print(f" 🥇 Refresh is {single_pop_time/single_refresh_time:.1f}x faster for single operation")
# Reset for multi-operation benchmark
gc.collect()
pop_store = MessageStore()
pop_store.load(envelopes.copy())
pop_list_view = MockListView()
for item in pop_store.envelopes:
if item and item.get("type") == "header":
pop_list_view.append(f"Header: {item['label']}")
elif item:
pop_list_view.append(f"Email: {item.get('subject', '')}")
refresh_store = MessageStore()
refresh_store.load(envelopes.copy())
refresh_list_view = MockListView()
for item in refresh_store.envelopes:
if item and item.get("type") == "header":
refresh_list_view.append(f"Header: {item['label']}")
elif item:
refresh_list_view.append(f"Email: {item.get('subject', '')}")
# Multiple operations benchmark
print(f"\n🔹 Multiple operations benchmark (removing {num_operations} items):")
# Pop approach - multiple operations
gc.collect()
multi_pop_time, pop_current, pop_peak = run_memory_benchmark(
benchmark_pop_approach, pop_store, pop_list_view, indices_to_remove
)
print(f" Pop approach: {multi_pop_time*1000:.2f} ms (Memory - Current: {pop_current/1024:.1f} KB, Peak: {pop_peak/1024:.1f} KB)")
# Refresh approach - multiple operations
gc.collect()
multi_refresh_time, refresh_current, refresh_peak = run_memory_benchmark(
benchmark_refresh_approach, refresh_store, refresh_list_view, indices_to_remove
)
print(f" Refresh approach: {multi_refresh_time*1000:.2f} ms (Memory - Current: {refresh_current/1024:.1f} KB, Peak: {refresh_peak/1024:.1f} KB)")
# Determine which is better for multiple operations
if multi_pop_time < multi_refresh_time:
print(f" 🥇 Pop is {multi_refresh_time/multi_pop_time:.1f}x faster for multiple operations")
else:
print(f" 🥇 Refresh is {multi_pop_time/multi_refresh_time:.1f}x faster for multiple operations")
return {
"envelope_count": envelope_count,
"num_operations": num_operations,
"single_operation": {
"pop_time_ms": single_pop_time * 1000,
"refresh_time_ms": single_refresh_time * 1000,
"pop_memory_kb": pop_peak / 1024,
"refresh_memory_kb": refresh_peak / 1024
},
"multiple_operations": {
"pop_time_ms": multi_pop_time * 1000,
"refresh_time_ms": multi_refresh_time * 1000,
"pop_memory_kb": pop_peak / 1024,
"refresh_memory_kb": refresh_peak / 1024
}
}
def main():
print("\n📊 MAILDIR GTD LIST UPDATE BENCHMARK 📊")
print("Comparing .pop() vs refresh_list_view() approaches")
print("=" * 60)
# Define test cases
envelope_counts = [100, 1000, 2000]
results = []
for count in envelope_counts:
result = run_benchmark(count)
results.append(result)
# Print summary
print("\n" + "=" * 60)
print("📊 BENCHMARK SUMMARY")
print("=" * 60)
# Console table formatting
print(f"{'Size':<10} | {'Single Op (pop)':<15} | {'Single Op (refresh)':<20} | {'Multi Op (pop)':<15} | {'Multi Op (refresh)':<20}")
print("-" * 90)
for result in results:
count = result["envelope_count"]
single_pop = f"{result['single_operation']['pop_time_ms']:.2f} ms"
single_refresh = f"{result['single_operation']['refresh_time_ms']:.2f} ms"
multi_pop = f"{result['multiple_operations']['pop_time_ms']:.2f} ms"
multi_refresh = f"{result['multiple_operations']['refresh_time_ms']:.2f} ms"
print(f"{count:<10} | {single_pop:<15} | {single_refresh:<20} | {multi_pop:<15} | {multi_refresh:<20}")
# Display conclusions
print("\n🔍 CONCLUSIONS:")
for result in results:
count = result["envelope_count"]
single_ratio = result['single_operation']['refresh_time_ms'] / result['single_operation']['pop_time_ms']
multi_ratio = result['multiple_operations']['refresh_time_ms'] / result['multiple_operations']['pop_time_ms']
print(f"\nFor {count} envelopes:")
if single_ratio > 1:
print(f"- Single operation: .pop() is {single_ratio:.1f}x faster")
else:
print(f"- Single operation: refresh_list_view() is {1/single_ratio:.1f}x faster")
if multi_ratio > 1:
print(f"- Multiple operations: .pop() is {multi_ratio:.1f}x faster")
else:
print(f"- Multiple operations: refresh_list_view() is {1/multi_ratio:.1f}x faster")
print("\n🔑 RECOMMENDATION:")
# Calculate average performance difference across all tests
avg_single_ratio = sum(r['single_operation']['refresh_time_ms'] / r['single_operation']['pop_time_ms'] for r in results) / len(results)
avg_multi_ratio = sum(r['multiple_operations']['refresh_time_ms'] / r['multiple_operations']['pop_time_ms'] for r in results) / len(results)
if avg_single_ratio > 1 and avg_multi_ratio > 1:
print("The .pop() approach is generally faster, but consider the following:")
print("- .pop() risks index misalignment issues with the message_store")
print("- refresh_list_view() ensures UI and data structure stay synchronized")
print("- The performance difference may not be noticeable to users")
print("👉 Recommendation: Use refresh_list_view() for reliability unless performance becomes a real issue")
else:
print("The refresh_list_view() approach is not only safer but also performs competitively:")
print("- It ensures perfect synchronization between UI and data model")
print("- It eliminates the risk of index misalignment")
print("👉 Recommendation: Use refresh_list_view() approach as it's more reliable and performs well")
if __name__ == "__main__":
main()

View File

@@ -2,47 +2,41 @@ import asyncio
import logging
from textual import work
from textual.logging import TextualHandler
from textual.widgets import ListView
logging.basicConfig(
level="NOTSET",
handlers=[TextualHandler()],
)
from apis.himalaya import client as himalaya_client
@work(exclusive=False)
async def archive_current(app) -> None:
"""Archive the current email message."""
try:
index = app.current_message_index
logging.info("Archiving message ID: " + str(app.current_message_id))
process = await asyncio.create_subprocess_shell(
f"himalaya message move Archives {app.current_message_id}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
app.show_status(f"{stdout.decode()}", "info")
logging.info(stdout.decode())
if process.returncode == 0:
# Remove the item from the ListView
app.query_one(ListView).pop(index)
@work(exclusive=True)
async def archive_current(app):
"""Archive the current message."""
if not app.current_message_id:
app.show_status("No message selected to archive.", "error")
return
# Find the next message to display using the MessageStore
next_id, next_idx = app.message_store.find_next_valid_id(index)
# Store the current message ID and index
current_message_id = app.current_message_id
current_index = app.current_message_index
# Show the next available message
# Find the next message to display after archiving
next_id, next_idx = app.message_store.find_next_valid_id(current_index)
if next_id is None or next_idx is None:
# If there's no next message, try to find a previous one
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)
if success:
app.show_status(f"Message {current_message_id} archived.", "success")
app.message_store.remove_envelope(current_message_id)
app.refresh_list_view()
# Select the next available message if it exists
if next_id is not None and next_idx is not None:
# Set ListView index first to ensure UI is synchronized
app.query_one(ListView).index = next_idx
# Now update the current_message_id to trigger content update
app.current_message_id = next_id
app.current_message_index = next_idx
else:
# No messages left, just update ListView
app.query_one(ListView).index = 0
app.reload_needed = True
# If there are no other messages, reset the UI
app.current_message_id = 0
app.show_status("No more messages available.", "warning")
else:
app.show_status(f"Error archiving message: {stderr.decode()}", "error")
except Exception as e:
app.show_status(f"Error: {e}", "error")
app.show_status(f"Failed to archive message {current_message_id}.", "error")

View File

@@ -1,41 +1,41 @@
import asyncio
import logging
from textual import work
from textual.widgets import ListView
from apis.himalaya import client as himalaya_client
@work(exclusive=False)
async def delete_current(app) -> None:
app.show_status(f"Deleting message {app.current_message_id}...")
try:
index = app.current_message_index
process = await asyncio.create_subprocess_shell(
f"himalaya message delete {app.current_message_id}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
app.show_status(f"{stdout.decode()}", "info")
if process.returncode == 0:
# Remove the item from the ListView
await app.query_one(ListView).pop(index)
@work(exclusive=True)
async def delete_current(app):
"""Delete the current message."""
if not app.current_message_id:
app.show_status("No message selected to delete.", "error")
return
# Find the next message to display using the MessageStore
next_id, next_idx = app.message_store.find_next_valid_id(index)
# Store the current message ID and index
current_message_id = app.current_message_id
current_index = app.current_message_index
# Show the next available message
# Find the next message to display after deletion
next_id, next_idx = app.message_store.find_next_valid_id(current_index)
if next_id is None or next_idx is None:
# If there's no next message, try to find a previous one
next_id, next_idx = app.message_store.find_prev_valid_id(current_index)
# Delete the message using our Himalaya client module
success = await himalaya_client.delete_message(current_message_id)
if success:
app.show_status(f"Message {current_message_id} deleted.", "success")
app.message_store.remove_envelope(current_message_id)
app.refresh_list_view()
# Select the next available message if it exists
if next_id is not None and next_idx is not None:
# Set ListView index first to ensure UI is synchronized
app.query_one(ListView).index = next_idx
# Now update the current_message_id to trigger content update
app.current_message_id = next_id
app.current_message_index = next_idx
else:
# No messages left, just update ListView
app.query_one(ListView).index = 0
app.reload_needed = True
# If there are no other messages, reset the UI
app.current_message_id = 0
app.show_status("No more messages available.", "warning")
else:
app.show_status(
f"Failed to delete message {app.current_message_id}. {stderr.decode()}",
"error",
)
except Exception as e:
app.show_status(f"Error: {e}", "error")
app.show_status(f"Failed to delete message {current_message_id}.", "error")

View File

@@ -1,28 +1,49 @@
import asyncio
import logging
from textual import work
from textual.screen import ModalScreen
from apis.taskwarrior import client as taskwarrior_client
from maildir_gtd.screens.CreateTask import CreateTaskScreen
class TaskAction:
def __init__(self, app):
self.app = app
def action_create_task(app) -> None:
"""Show the input modal for creating a task."""
def action_create_task(app):
"""Show the create task screen."""
async def check_task(task_args: str) -> bool:
current_message_id = app.current_message_id
if not current_message_id:
app.show_status("No message selected to create task from.", "error")
return
# Prepare data for the create task screen
metadata = app.message_store.get_metadata(current_message_id)
subject = metadata.get("subject", "No subject") if metadata else "No subject"
from_addr = metadata["from"].get("addr", "Unknown") if metadata else "Unknown"
# Show the create task screen with the current message data
app.push_screen(CreateTaskScreen(subject=subject, from_addr=from_addr))
@work(exclusive=True)
async def create_task(subject, description=None, tags=None, project=None, due=None, priority=None):
"""
Create a task with the Taskwarrior API client.
"""
try:
result = await asyncio.create_subprocess_shell(
f"task add {task_args}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
success, result = await taskwarrior_client.create_task(
task_description=subject,
tags=tags or [],
project=project,
due=due,
priority=priority
)
stdout, stderr = await result.communicate()
if result.returncode == 0:
app.show_status(f"Task created: {stdout.decode()}")
else:
app.show_status(
f"Failed to create task: {stderr.decode()}", severity="error"
)
except Exception as e:
app.show_status(f"Error: {e}", severity="error")
return True
return False
app.push_screen(CreateTaskScreen(), check_task)
if success:
return True, result
else:
logging.error(f"Failed to create task: {result}")
return False, result
except Exception as e:
logging.error(f"Exception creating task: {e}")
return False, str(e)

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:
# 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
msglist.clear()
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,
)
)
)
# 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 any envelopes.", "error")
self.show_status("Failed to fetch envelopes.", "error")
except Exception as e:
self.show_status(f"Error fetching message list: {e}", "error")
finally:
@@ -442,18 +263,11 @@ 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:
# 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(
@@ -463,6 +277,8 @@ class EmailViewerApp(App):
)
)
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,18 +293,11 @@ 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:
# 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(
@@ -498,11 +307,43 @@ class EmailViewerApp(App):
)
)
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)

View File

@@ -153,6 +153,12 @@ Label.group_header {
width: 100%;
}
#html_content {
padding: 1 2;
height: auto;
width: 100%;
}
.hidden {
display: none;
}

View File

@@ -0,0 +1,143 @@
import logging
from typing import List, Dict, Any, Tuple, Optional
from datetime import datetime, UTC
from apis.himalaya import client as himalaya_client
class MessageStore:
"""Store and manage message envelopes"""
def __init__(self):
self.envelopes: List[Dict[str, Any]] = []
self.metadata_by_id: Dict[int, Dict[str, Any]] = {}
self.total_messages = 0
def load(self, envelopes: List[Dict[str, Any]], sort_ascending: bool = True) -> None:
"""Load envelopes from Himalaya client and process them"""
if not envelopes:
self.envelopes = []
self.metadata_by_id = {}
self.total_messages = 0
return
# Sort by date
envelopes.sort(
key=lambda x: x.get("date", ""),
reverse=not sort_ascending,
)
# Group envelopes by month
grouped_envelopes = []
months = {}
for envelope in envelopes:
if "id" not in envelope:
continue
# Extract date and determine month group
date_str = envelope.get("date", "")
try:
date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
month_key = date.strftime("%B %Y")
except (ValueError, TypeError):
month_key = "Unknown Date"
# Add month header if this is a new month
if month_key not in months:
months[month_key] = True
grouped_envelopes.append({"type": "header", "label": month_key})
# Add the envelope
grouped_envelopes.append(envelope)
# Store metadata for quick access
envelope_id = int(envelope["id"])
self.metadata_by_id[envelope_id] = {
"id": envelope_id,
"subject": envelope.get("subject", ""),
"from": envelope.get("from", {}),
"to": envelope.get("to", {}),
"cc": envelope.get("cc", {}),
"date": date_str,
"index": len(grouped_envelopes) - 1,
}
self.envelopes = grouped_envelopes
self.total_messages = len(self.metadata_by_id)
async def reload(self, sort_ascending: bool = True) -> None:
"""Reload envelopes from the Himalaya client"""
envelopes, success = await himalaya_client.list_envelopes()
if success:
self.load(envelopes, sort_ascending)
else:
logging.error("Failed to reload envelopes")
def get_metadata(self, message_id: int) -> Optional[Dict[str, Any]]:
"""Get metadata for a message by ID"""
return self.metadata_by_id.get(message_id)
def find_next_valid_id(self, current_index: int) -> Tuple[Optional[int], Optional[int]]:
"""Find the next valid message ID and its index"""
if not self.envelopes or current_index >= len(self.envelopes) - 1:
return None, None
# Start from current index + 1
for idx in range(current_index + 1, len(self.envelopes)):
item = self.envelopes[idx]
# Skip header items
if item and item.get("type") != "header" and "id" in item:
return int(item["id"]), idx
return None, None
def find_prev_valid_id(self, current_index: int) -> Tuple[Optional[int], Optional[int]]:
"""Find the previous valid message ID and its index"""
if not self.envelopes or current_index <= 0:
return None, None
# Start from current index - 1
for idx in range(current_index - 1, -1, -1):
item = self.envelopes[idx]
# Skip header items
if item and item.get("type") != "header" and "id" in item:
return int(item["id"]), idx
return None, None
def get_oldest_id(self) -> int:
"""Get the ID of the oldest message (first non-header item)"""
for item in self.envelopes:
if item and item.get("type") != "header" and "id" in item:
return int(item["id"])
return 0
def get_newest_id(self) -> int:
"""Get the ID of the newest message (last non-header item)"""
for item in reversed(self.envelopes):
if item and item.get("type") != "header" and "id" in item:
return int(item["id"])
return 0
def remove_envelope(self, message_id: int) -> None:
"""Remove an envelope from the store"""
metadata = self.metadata_by_id.get(message_id)
if not metadata:
return
index = metadata["index"]
if 0 <= index < len(self.envelopes):
# Remove from the envelopes list
self.envelopes.pop(index)
# Remove from metadata dictionary
del self.metadata_by_id[message_id]
# Update indexes for all subsequent messages
for id_, meta in self.metadata_by_id.items():
if meta["index"] > index:
meta["index"] -= 1
# Update total message count
self.total_messages = len(self.metadata_by_id)
else:
logging.warning(f"Invalid index {index} for message ID {message_id}")

View File

@@ -1,43 +1,105 @@
from textual import on
from textual.app import ComposeResult
import logging
from textual.screen import ModalScreen
from textual.widgets import Input, Label, Button
from textual.containers import Horizontal, Vertical
from textual.widgets import Input, Label, Button, ListView, ListItem
from textual.containers import Vertical, Horizontal, Container
from textual import on, work
from apis.taskwarrior import client as taskwarrior_client
class CreateTaskScreen(ModalScreen):
"""Screen for creating a new task."""
class CreateTaskScreen(ModalScreen[str]):
def compose(self) -> ComposeResult:
yield Vertical(
def __init__(self, subject="", from_addr="", **kwargs):
super().__init__(**kwargs)
self.subject = subject
self.from_addr = from_addr
self.selected_project = None
def compose(self):
yield Container(
Vertical(
Label("Create Task", id="create_task_title"),
Horizontal(
Label("$>", id="task_prompt"),
Label("task add ", id="task_prompt_label"),
Input(placeholder="arguments", id="task_input"),
Label("Subject:"),
Input(placeholder="Task subject", value=self.subject, id="subject_input"),
),
Horizontal(
Button("Cancel", id="cancel"),
Button("Submit", id="submit", variant="primary"),
Label("Project:"),
Input(placeholder="Project name", id="project_input"),
),
Horizontal(
Label("Tags:"),
Input(placeholder="Comma-separated tags", id="tags_input"),
),
Horizontal(
Label("Due:"),
Input(placeholder="Due date (e.g., today, tomorrow, fri)", id="due_input"),
),
Horizontal(
Label("Priority:"),
Input(placeholder="Priority (H, M, L)", id="priority_input"),
),
Horizontal(
Button("Create", id="create_btn", variant="primary"),
Button("Cancel", id="cancel_btn", variant="error"),
),
id="create_task_form",
),
id="create_task_container",
classes="modal_screen",
)
@on(Input.Submitted)
def handle_task_args(self) -> None:
input_widget = self.query_one("#task_input", Input)
self.visible = False
self.disabled = True
self.loading = True
task_args = input_widget.value
self.dismiss(task_args)
def on_mount(self):
self.styles.align = ("center", "middle")
def on_key(self, event) -> None:
if event.key == "escape" or event.key == "ctrl+c":
@on(Button.Pressed, "#create_btn")
def on_create_pressed(self):
"""Create the task when the Create button is pressed."""
# Get input values
subject = self.query_one("#subject_input").value
project = self.query_one("#project_input").value
tags_input = self.query_one("#tags_input").value
due = self.query_one("#due_input").value
priority = self.query_one("#priority_input").value
# Process tags (split by commas and trim whitespace)
tags = [tag.strip() for tag in tags_input.split(",")] if tags_input else []
# Add a tag for the sender, if provided
if self.from_addr and "@" in self.from_addr:
domain = self.from_addr.split("@")[1].split(".")[0]
if domain and domain not in ["gmail", "yahoo", "hotmail", "outlook"]:
tags.append(domain)
# Create the task
self.create_task_worker(subject, tags, project, due, priority)
@on(Button.Pressed, "#cancel_btn")
def on_cancel_pressed(self):
"""Dismiss the screen when Cancel is pressed."""
self.dismiss()
def button_on_click(self, event):
if event.button.id == "cancel":
@work(exclusive=True)
async def create_task_worker(self, subject, tags=None, project=None, due=None, priority=None):
"""Worker to create a task using the Taskwarrior API client."""
if not subject:
self.app.show_status("Task subject cannot be empty.", "error")
return
# Validate priority
if priority and priority not in ["H", "M", "L"]:
self.app.show_status("Priority must be H, M, or L.", "warning")
priority = None
# Create the task
success, result = await taskwarrior_client.create_task(
task_description=subject,
tags=tags or [],
project=project,
due=due,
priority=priority
)
if success:
self.app.show_status(f"Task created: {subject}", "success")
self.dismiss()
elif event.button.id == "submit":
input_widget = self.query_one("#task_input", Input)
task_args = input_widget.value
self.dismiss(task_args)
else:
self.app.show_status(f"Failed to create task: {result}", "error")

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import re
from typing import List, Dict
@@ -6,7 +6,7 @@ from typing import List, Dict
def group_envelopes_by_date(envelopes: List[Dict]) -> List[Dict]:
"""Group envelopes by date and add headers for each group."""
grouped_envelopes = []
today = datetime.now()
today = datetime.now().astimezone(UTC)
yesterday = today - timedelta(days=1)
start_of_week = today - timedelta(days=today.weekday())
start_of_last_week = start_of_week - timedelta(weeks=1)
@@ -32,7 +32,7 @@ def group_envelopes_by_date(envelopes: List[Dict]) -> List[Dict]:
current_group = None
for envelope in envelopes:
envelope_date = re.sub(r"[\+\-]\d\d:\d\d", "", envelope["date"])
envelope_date = datetime.strptime(envelope_date, "%Y-%m-%d %H:%M")
envelope_date = datetime.strptime(envelope_date, "%Y-%m-%d %H:%M").astimezone(UTC)
group_label = get_group_label(envelope_date)
if group_label != current_group:
grouped_envelopes.append({"type": "header", "label": group_label})

View File

@@ -1,186 +1,154 @@
import re
import asyncio
import logging
from functools import lru_cache
from datetime import datetime
import re
from textual.widgets import Static, Markdown, Label
from textual.containers import Vertical, Horizontal, ScrollableContainer
from textual import work
from textual.app import ComposeResult
from textual.widgets import Label, Markdown
from textual.containers import ScrollableContainer
from textual.worker import Worker
from apis.himalaya import client as himalaya_client
from widgets.EnvelopeHeader import EnvelopeHeader
class EnvelopeHeader(Vertical):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.subject_label = Label("")
self.from_label = Label("")
self.to_label = Label("")
self.date_label = Label("")
self.cc_label = Label("")
def on_mount(self):
self.styles.height = "auto"
self.mount(self.subject_label)
self.mount(self.from_label)
self.mount(self.to_label)
self.mount(self.cc_label)
self.mount(self.date_label)
def update(self, subject, from_, to, date, cc=None):
self.subject_label.update(f"[b]Subject:[/b] {subject}")
self.from_label.update(f"[b]From:[/b] {from_}")
self.to_label.update(f"[b]To:[/b] {to}")
# Format the date for better readability
if date:
try:
# Try to convert the date string to a datetime object
date_obj = datetime.fromisoformat(date.replace('Z', '+00:00'))
formatted_date = date_obj.strftime("%a, %d %b %Y %H:%M:%S %Z")
self.date_label.update(f"[b]Date:[/b] {formatted_date}")
except (ValueError, TypeError):
# If parsing fails, just use the original date string
self.date_label.update(f"[b]Date:[/b] {date}")
else:
self.date_label.update("[b]Date:[/b] Unknown")
if cc:
self.cc_label.update(f"[b]CC:[/b] {cc}")
self.cc_label.styles.display = "block"
else:
self.cc_label.styles.display = "none"
class ContentContainer(ScrollableContainer):
"""A custom container that can switch between plaintext and markdown rendering."""
can_focus = True
def __init__(self, **kwargs):
super().__init__(**kwargs)
# self.header = EnvelopeHeader(id="envelope_header")
self.content = Markdown("", id="markdown_content")
self.html_content = Static("", id="html_content", markup=False)
self.current_mode = "html" # Default to HTML mode
self.current_content = None
self.current_message_id = None
self.content_worker = None
def compose(self):
yield self.content
yield self.html_content
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.plaintext_mode = True
self.markup_worker = None
self.current_text = ""
self.current_id = None
self.message_cache = dict()
# LRU cache with a max size of 100 messages
def on_mount(self):
# Hide markdown content initially
self.content.styles.display = "none"
self.html_content.styles.display = "block"
def compose(self) -> ComposeResult:
"""Compose the container with a label for plaintext and markdown for rich content."""
yield EnvelopeHeader()
yield Label(id="plaintext_content", markup=False)
yield Markdown(id="markdown_content", classes="hidden")
def update_header(self, subject: str = "", date: str = "", from_: str = "", to: str = "", cc: str = "", bcc: str = "") -> None:
header = self.query_one(EnvelopeHeader)
header.subject = subject
header.date = date
header.from_ = from_
header.to = to
header.cc = cc
header.bcc = bcc
def action_toggle_header(self) -> None:
"""Toggle the visibility of the EnvelopeHeader panel."""
header = self.query_one(EnvelopeHeader)
header.styles.height = "1" if self.header_expanded else "auto"
self.header_expanded = not self.header_expanded
def display_content(self, message_id: int) -> None:
"""Display content for the given message ID."""
self.current_id = message_id
# Show loading state
self.loading = True
# Check if the message is already cached
if message_id in self.message_cache:
self.current_text = self.message_cache[message_id]
plaintext = self.query_one("#plaintext_content", Label)
plaintext.update(self.current_text)
if not self.plaintext_mode:
# We're in markdown mode, so render the markdown
self.render_markdown()
async def toggle_mode(self):
"""Toggle between plaintext and HTML viewing modes."""
if self.current_mode == "html":
self.current_mode = "text"
self.html_content.styles.display = "none"
self.content.styles.display = "block"
else:
# Hide markdown, show plaintext
plaintext.remove_class("hidden")
self.query_one("#markdown_content").add_class("hidden")
self.current_mode = "html"
self.content.styles.display = "none"
self.html_content.styles.display = "block"
self.loading = False
return self.current_text
else:
# Get message body (from cache or fetch new)
self.get_message_body(message_id)
# Reload the content if we have a message ID
if self.current_message_id:
self.display_content(self.current_message_id)
# def update_header(self, subject, from_, to, date, cc=None):
# self.header.update(subject, from_, to, date, cc)
@work(exclusive=True)
async def get_message_body(self, message_id: int) -> str:
"""Fetch the message body from Himalaya CLI."""
async def fetch_message_content(self, message_id: int, format: str):
"""Fetch message content using the Himalaya client module."""
if not message_id:
self.notify("No message ID provided.")
return
content, success = await himalaya_client.get_message_content(message_id, format)
if success:
self._update_content(content)
else:
self.notify(f"Failed to fetch content for message ID {message_id}.")
def display_content(self, message_id: int) -> None:
"""Display the content of a message."""
if not message_id:
return
self.current_message_id = message_id
# Cancel any existing content fetch operations
if self.content_worker:
self.content_worker.cancel()
# Fetch content in the current mode
format_type = "text" if self.current_mode == "text" else "html"
self.content_worker = self.fetch_message_content(message_id, format_type)
def _update_content(self, content: str) -> None:
"""Update the content widgets with the fetched content."""
try:
# Store the ID of the message we're currently loading
loading_id = message_id
if self.current_mode == "text":
# For text mode, use the Markdown widget
process = await asyncio.create_subprocess_shell(
f"himalaya message read {str(message_id)} -p",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
logging.info(f"stdout: {stdout.decode()[0:50]}...")
# Check if we're still loading the same message or if navigation has moved on
if loading_id != self.current_id:
logging.info(f"Message ID changed during loading. Abandoning load of {loading_id}")
return ""
if process.returncode == 0:
# Process the email content
content = stdout.decode()
# Remove header lines from the beginning of the message
# Headers typically end with a blank line before the message body
lines = content.split('\n')
body_start = 0
# Find the first blank line which typically separates headers from body
for i, line in enumerate(lines):
if line.strip() == '' and i > 0:
# Check if we're past the headers section
# Headers are typically in "Key: Value" format
has_headers = any(': ' in l for l in lines[:i])
if has_headers:
body_start = i + 1
break
# Join the body lines back together
content = '\n'.join(lines[body_start:])
# Apply existing cleanup logic
fixed_text = content.replace("https://urldefense.com/v3/", "")
fixed_text = re.sub(r"atlOrigin.+?\w", "", fixed_text)
logging.info(f"rendering fixedText: {fixed_text[0:50]}")
self.current_text = fixed_text
self.message_cache[message_id] = fixed_text
# Check again if we're still on the same message before updating UI
if loading_id != self.current_id:
logging.info(f"Message ID changed after loading. Abandoning update for {loading_id}")
return fixed_text
# Update the plaintext content
plaintext = self.query_one("#plaintext_content", Label)
plaintext.update(fixed_text)
if not self.plaintext_mode:
# We're in markdown mode, so render the markdown
self.render_markdown()
self.content.update(content)
else:
# Hide markdown, show plaintext
plaintext.remove_class("hidden")
self.query_one("#markdown_content").add_class("hidden")
# For HTML mode, use the Static widget with markup
# First, try to extract the body content if it's HTML
body_match = re.search(r'<body[^>]*>(.*?)</body>', content, re.DOTALL | re.IGNORECASE)
if body_match:
content = body_match.group(1)
# Replace some common HTML elements with Textual markup
content = content.replace('<b>', '[b]').replace('</b>', '[/b]')
content = content.replace('<i>', '[i]').replace('</i>', '[/i]')
content = content.replace('<u>', '[u]').replace('</u>', '[/u]')
# Convert links to a readable format
content = re.sub(r'<a href="([^"]+)"[^>]*>([^<]+)</a>', r'[\2](\1)', content)
# Add CSS for better readability
self.html_content.update(content)
self.loading = False
return fixed_text
else:
logging.error(f"Error fetching message: {stderr.decode()}")
self.loading = False
return f"Error fetching message content: {stderr.decode()}"
except Exception as e:
logging.error(f"Error fetching message content: {e}")
self.loading = False
return f"Error fetching message content: {e}"
finally:
# Ensure loading state is always reset if this worker completes
# This prevents the loading indicator from getting stuck
if loading_id == self.current_id:
self.loading = False
async def render_markdown(self) -> None:
"""Render the markdown content asynchronously."""
if self.markup_worker:
self.markup_worker.cancel()
markdown = self.query_one("#markdown_content", Markdown)
plaintext = self.query_one("#plaintext_content", Label)
await markdown.update(self.current_text)
# Show markdown, hide plaintext
markdown.remove_class("hidden")
plaintext.add_class("hidden")
async def toggle_mode(self) -> None:
"""Toggle between plaintext and markdown mode."""
self.plaintext_mode = not self.plaintext_mode
if self.plaintext_mode:
# Switch to plaintext
self.query_one("#plaintext_content").remove_class("hidden")
self.query_one("#markdown_content").add_class("hidden")
logging.error(f"Error updating content: {e}")
if self.current_mode == "text":
self.content.update(f"Error displaying content: {e}")
else:
# Switch to markdown
await self.render_markdown()
return self.plaintext_mode
self.html_content.update(f"Error displaying content: {e}")

View File

@@ -2,6 +2,9 @@ from textual.reactive import Reactive
from textual.app import ComposeResult
from textual.widgets import Label
from textual.containers import Horizontal, ScrollableContainer
from datetime import datetime
import re
from datetime import UTC
class EnvelopeHeader(ScrollableContainer):
@@ -55,8 +58,49 @@ class EnvelopeHeader(ScrollableContainer):
# self.query_one("#from").update(from_)
def watch_date(self, date: str) -> None:
"""Watch the date for changes."""
"""Watch the date for changes and convert to local timezone."""
if date:
try:
# If date already has timezone info, parse it
if any(x in date for x in ['+', '-', 'Z']):
# Try parsing with timezone info
try:
# Handle ISO format with Z suffix
if 'Z' in date:
parsed_date = datetime.fromisoformat(date.replace('Z', '+00:00'))
else:
parsed_date = datetime.fromisoformat(date)
except ValueError:
# Try another common format
parsed_date = datetime.strptime(date, "%Y-%m-%d %H:%M%z")
else:
# No timezone info, assume UTC
try:
parsed_date = datetime.strptime(date, "%Y-%m-%d %H:%M").replace(tzinfo=UTC)
except ValueError:
# If regular parsing fails, try to extract date components
match = re.search(r"(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})", date)
if match:
date_part, time_part = match.groups()
parsed_date = datetime.strptime(
f"{date_part} {time_part}", "%Y-%m-%d %H:%M"
).replace(tzinfo=UTC)
else:
# If all else fails, just use the original string
self.query_one("#date").update(date)
return
# Convert to local timezone
local_date = parsed_date.astimezone() # Convert to system's local timezone
# Format for display
formatted_date = local_date.strftime("%a %b %d %H:%M (%Z)")
self.query_one("#date").update(formatted_date)
except Exception as e:
# If parsing fails, just display the original date
self.query_one("#date").update(f"{date}")
else:
self.query_one("#date").update("")
# def watch_cc(self, cc: str) -> None:
# """Watch the cc field for changes."""

View File

@@ -5,6 +5,7 @@ import os
import json
import time
from datetime import datetime
import email.utils
def truncate_id(message_id, length=8):
"""
@@ -67,6 +68,24 @@ def format_datetime(dt_str, format_string="%m/%d %I:%M %p"):
except (ValueError, AttributeError):
return dt_str
def format_mime_date(dt_str):
"""
Format a datetime string from ISO format to RFC 5322 format for MIME Date headers.
Args:
dt_str (str): ISO format datetime string.
Returns:
str: Formatted datetime string in RFC 5322 format.
"""
if not dt_str:
return ""
try:
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
return email.utils.format_datetime(dt)
except (ValueError, AttributeError):
return dt_str
def safe_filename(filename):
"""
Convert a string to a safe filename.

View File

@@ -13,7 +13,7 @@ import aiohttp
import re
from utils.calendar_utils import truncate_id
from utils.mail_utils.helpers import safe_filename, ensure_directory_exists, format_datetime
from utils.mail_utils.helpers import safe_filename, ensure_directory_exists, format_datetime, format_mime_date
async def save_mime_to_maildir_async(maildir_path, message, attachments_dir, headers, progress, dry_run=False, download_attachments=False):
"""
@@ -92,10 +92,10 @@ async def create_mime_message_async(message, headers, attachments_dir, progress,
cc_list = [f"{r.get('emailAddress', {}).get('name', '')} <{r.get('emailAddress', {}).get('address', '')}>".strip() for r in cc_recipients]
mime_msg['Cc'] = ', '.join(cc_list)
# Date
# Date - using the new format_mime_date function to ensure RFC 5322 compliance
received_datetime = message.get('receivedDateTime', '')
if received_datetime:
mime_msg['Date'] = received_datetime
mime_msg['Date'] = format_mime_date(received_datetime)
# First try the direct body content approach
message_id = message.get('id', '')