diff --git a/apis/__init__.py b/apis/__init__.py new file mode 100644 index 0000000..b5fbb41 --- /dev/null +++ b/apis/__init__.py @@ -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 +""" diff --git a/apis/himalaya/__init__.py b/apis/himalaya/__init__.py new file mode 100644 index 0000000..734294f --- /dev/null +++ b/apis/himalaya/__init__.py @@ -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", +] diff --git a/apis/himalaya/client.py b/apis/himalaya/client.py new file mode 100644 index 0000000..171adf6 --- /dev/null +++ b/apis/himalaya/client.py @@ -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 diff --git a/apis/microsoft_graph/auth.py b/apis/microsoft_graph/auth.py index fa657c5..9e304ef 100644 --- a/apis/microsoft_graph/auth.py +++ b/apis/microsoft_graph/auth.py @@ -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 diff --git a/apis/microsoft_graph/mail.py b/apis/microsoft_graph/mail.py index 7d9143c..0463d7b 100644 --- a/apis/microsoft_graph/mail.py +++ b/apis/microsoft_graph/mail.py @@ -152,7 +152,7 @@ async def delete_mail_async(maildir_path, headers, progress, task_id, dry_run=Fa progress.console.print(f"[DRY-RUN] Would delete message: {message_id}") progress.advance(task_id) -async def synchronize_maildir_async(maildir_path, headers, progress, task_id, dry_run=False): +async def synchronize_maildir_async(maildir_path, headers, progress, task_id, dry_run=False): """ Synchronize Maildir with Microsoft Graph API. diff --git a/apis/taskwarrior/__init__.py b/apis/taskwarrior/__init__.py new file mode 100644 index 0000000..4d21a33 --- /dev/null +++ b/apis/taskwarrior/__init__.py @@ -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", +] diff --git a/apis/taskwarrior/client.py b/apis/taskwarrior/client.py new file mode 100644 index 0000000..1b1a838 --- /dev/null +++ b/apis/taskwarrior/client.py @@ -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 diff --git a/benchmark_list_update.py b/benchmark_list_update.py new file mode 100755 index 0000000..b9d5e14 --- /dev/null +++ b/benchmark_list_update.py @@ -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() diff --git a/maildir_gtd/actions/archive.py b/maildir_gtd/actions/archive.py index 397c56f..11d9475 100644 --- a/maildir_gtd/actions/archive.py +++ b/maildir_gtd/actions/archive.py @@ -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 - 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 - else: - # No messages left, just update ListView - app.query_one(ListView).index = 0 - app.reload_needed = True + # 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: + app.current_message_id = next_id + app.current_message_index = next_idx else: - app.show_status(f"Error archiving message: {stderr.decode()}", "error") - except Exception as e: - app.show_status(f"Error: {e}", "error") + # 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 archive message {current_message_id}.", "error") diff --git a/maildir_gtd/actions/delete.py b/maildir_gtd/actions/delete.py index 73053cc..cc59644 100644 --- a/maildir_gtd/actions/delete.py +++ b/maildir_gtd/actions/delete.py @@ -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 - 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 - else: - # No messages left, just update ListView - app.query_one(ListView).index = 0 - app.reload_needed = True + # 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: + app.current_message_id = next_id + app.current_message_index = next_idx 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") + # 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 {current_message_id}.", "error") diff --git a/maildir_gtd/actions/task.py b/maildir_gtd/actions/task.py index 24d2126..6839fbe 100644 --- a/maildir_gtd/actions/task.py +++ b/maildir_gtd/actions/task.py @@ -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: - try: - result = await asyncio.create_subprocess_shell( - f"task add {task_args}", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - 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 + current_message_id = app.current_message_id + if not current_message_id: + app.show_status("No message selected to create task from.", "error") + return - app.push_screen(CreateTaskScreen(), check_task) + # 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: + success, result = await taskwarrior_client.create_task( + task_description=subject, + tags=tags or [], + project=project, + due=due, + priority=priority + ) + + 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) diff --git a/maildir_gtd/app.py b/maildir_gtd/app.py index b150469..83a21e4 100644 --- a/maildir_gtd/app.py +++ b/maildir_gtd/app.py @@ -1,17 +1,15 @@ import re import sys import os -from datetime import datetime +from datetime import UTC, datetime import asyncio import logging from typing import Iterable, Optional, List, Dict, Any, Generator, Tuple +from collections import defaultdict # Add the parent directory to the system path to resolve relative imports sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - - from textual import work from textual.worker import Worker from textual.app import App, ComposeResult, SystemCommand, RenderResult @@ -23,175 +21,25 @@ from textual.binding import Binding from textual.timer import Timer from textual.containers import ScrollableContainer, Vertical, Horizontal -from actions.archive import archive_current -from actions.delete import delete_current -from actions.open import action_open -from actions.task import action_create_task -from widgets.EnvelopeHeader import EnvelopeHeader -from widgets.ContentContainer import ContentContainer -from maildir_gtd.utils import group_envelopes_by_date +# Import our new API modules +from apis.himalaya import client as himalaya_client +from apis.taskwarrior import client as taskwarrior_client + +# Updated imports with correct relative paths +from maildir_gtd.actions.archive import archive_current +from maildir_gtd.actions.delete import delete_current +from maildir_gtd.actions.open import action_open +from maildir_gtd.actions.task import action_create_task +from maildir_gtd.widgets.EnvelopeHeader import EnvelopeHeader +from maildir_gtd.widgets.ContentContainer import ContentContainer + +from maildir_gtd.message_store import MessageStore logging.basicConfig( level="NOTSET", handlers=[TextualHandler()], ) - -class MessageStore: - """Centralized store for email message data with efficient lookups and updates.""" - - def __init__(self): - self.envelopes: List[Dict[str, Any]] = [] # Full envelope data including headers - self.by_id: Dict[int, Dict[str, Any]] = {} # Map message IDs to envelope data - self.id_to_index: Dict[int, int] = {} # Map message IDs to list indices - self.total_messages = 0 - self.sort_ascending = True - - def clear(self) -> None: - """Clear all data structures.""" - self.envelopes = [] - self.by_id = {} - self.id_to_index = {} - self.total_messages = 0 - - def load(self, raw_envelopes: List[Dict[str, Any]], sort_ascending: bool = True) -> None: - """Load envelopes from raw data and set up the data structures.""" - self.clear() - self.sort_ascending = sort_ascending - - # Sort the envelopes by date - sorted_envelopes = sorted( - raw_envelopes, - key=lambda x: x["date"], - reverse=not sort_ascending, - ) - - # Group them by date for display - self.envelopes = group_envelopes_by_date(sorted_envelopes) - - # Build lookup dictionaries - for idx, envelope in enumerate(self.envelopes): - if "id" in envelope and envelope.get("type") != "header": - msg_id = int(envelope["id"]) - self.by_id[msg_id] = envelope - self.id_to_index[msg_id] = idx - - # Count actual messages (excluding headers) - self.total_messages = len(self.by_id) - - def get_by_id(self, msg_id: int) -> Optional[Dict[str, Any]]: - """Get an envelope by its ID.""" - return self.by_id.get(msg_id) - - def get_index_by_id(self, msg_id: int) -> Optional[int]: - """Get the list index for a message ID.""" - return self.id_to_index.get(msg_id) - - def get_metadata(self, msg_id: int) -> Dict[str, Any]: - """Get essential metadata for a message.""" - envelope = self.get_by_id(msg_id) - if not envelope: - return {} - - return { - "subject": envelope.get("subject", ""), - "from": envelope.get("from", {}), - "to": envelope.get("to", {}), - "date": envelope.get("date", ""), - "cc": envelope.get("cc", {}), - "index": self.get_index_by_id(msg_id), - } - - def remove(self, msg_id: int) -> None: - """Remove a message from all data structures.""" - # Get the index first before we remove from dictionaries - idx = self.id_to_index.get(msg_id) - - # Remove from dictionaries - self.by_id.pop(msg_id, None) - self.id_to_index.pop(msg_id, None) - - # Remove from list if we found an index - if idx is not None: - self.envelopes[idx] = None # Mark as None rather than removing to maintain indices - - # Update total count - self.total_messages = len(self.by_id) - - def find_next_valid_id(self, current_idx: int) -> Tuple[Optional[int], Optional[int]]: - """Find the next valid message ID and its index after the current index.""" - # Look forward first - try: - # Optimized with better short-circuit logic - # Only check type if env exists and has an ID - idx, envelope = next( - (i, env) for i, env in enumerate(self.envelopes[current_idx + 1:], current_idx + 1) - if env and "id" in env and env.get("type") != "header" - ) - return int(envelope["id"]), idx - except StopIteration: - # If not found in forward direction, look from beginning - try: - idx, envelope = next( - (i, env) for i, env in enumerate(self.envelopes[:current_idx]) - if env and "id" in env and env.get("type") != "header" - ) - return int(envelope["id"]), idx - except StopIteration: - return None, None - - def find_prev_valid_id(self, current_idx: int) -> Tuple[Optional[int], Optional[int]]: - """Find the previous valid message ID and its index before the current index.""" - # Look backward first - try: - # Create a range of indices in reverse order - backward_range = range(current_idx - 1, -1, -1) # No need to convert to list - # Using optimized short-circuit evaluation - idx, envelope = next( - (i, self.envelopes[i]) for i in backward_range - if self.envelopes[i] and "id" in self.envelopes[i] and self.envelopes[i].get("type") != "header" - ) - return int(envelope["id"]), idx - except StopIteration: - # If not found, look from end downward to current - try: - backward_range = range(len(self.envelopes) - 1, current_idx, -1) # No need to convert to list - idx, envelope = next( - (i, self.envelopes[i]) for i in backward_range - if self.envelopes[i] and "id" in self.envelopes[i] and self.envelopes[i].get("type") != "header" - ) - return int(envelope["id"]), idx - except StopIteration: - return None, None - - def get_oldest_id(self) -> Optional[int]: - """Get the ID of the oldest message.""" - if not self.envelopes: - return None - - for envelope in self.envelopes: - if envelope and "id" in envelope and envelope.get("type") != "header": - return int(envelope["id"]) - - return None - - def get_newest_id(self) -> Optional[int]: - """Get the ID of the newest message.""" - if not self.envelopes: - return None - - for envelope in reversed(self.envelopes): - if envelope and "id" in envelope and envelope.get("type") != "header": - return int(envelope["id"]) - - return None - - def get_valid_envelopes(self) -> Generator[Dict[str, Any], None, None]: - """Get all valid (non-header) envelopes.""" - return (envelope for envelope in self.envelopes - if envelope and "id" in envelope and envelope.get("type") != "header") - - class StatusTitle(Static): total_messages: Reactive[int] = reactive(0) current_message_index: Reactive[int] = reactive(0) @@ -293,11 +141,11 @@ class EmailViewerApp(App): self.theme = "monokai" self.title = "MaildirGTD" self.query_one("#main_content").border_title = self.status_title - sort_indicator = "\u2191" if self.sort_order_ascending else "\u2193" - self.query_one("#envelopes_list").border_title = f"\[1] Emails {sort_indicator}" - self.query_one("#accounts_list").border_title = "\[2] Accounts" + sort_indicator = "ā" if self.sort_order_ascending else "ā" + self.query_one("#envelopes_list").border_title = f"1ļøā£ Emails {sort_indicator}" + self.query_one("#accounts_list").border_title = "2ļøā£ Accounts" - self.query_one("#folders_list").border_title = "\[3] Folders" + self.query_one("#folders_list").border_title = "3ļøā£ Folders" self.fetch_accounts() self.fetch_folders() @@ -314,8 +162,8 @@ class EmailViewerApp(App): def watch_sort_order_ascending(self, old_value: bool, new_value: bool) -> None: """Update the border title of the envelopes list when the sort order changes.""" - sort_indicator = "\u2191" if new_value else "\u2193" - self.query_one("#envelopes_list").border_title = f"\[1] Emails {sort_indicator}" + sort_indicator = "ā" if new_value else "ā" + self.query_one("#envelopes_list").border_title = f"1ļøā£ Emails {sort_indicator}" def watch_current_message_index(self, old_index: int, new_index: int) -> None: if new_index < 0: @@ -353,21 +201,19 @@ class EmailViewerApp(App): metadata = self.message_store.get_metadata(new_message_id) if metadata: - message_date = re.sub(r"[\+\-]\d\d:\d\d", "", metadata["date"]) - message_date = datetime.strptime(message_date, "%Y-%m-%d %H:%M").strftime( - "%a %b %d %H:%M" - ) + # Pass the complete date string with timezone information + message_date = metadata["date"] if self.current_message_index != metadata["index"]: self.current_message_index = metadata["index"] - content_container.update_header( - subject=metadata.get("subject", "").strip(), - from_=metadata["from"].get("addr", ""), - to=metadata["to"].get("addr", ""), - date=message_date, - cc=metadata["cc"].get("addr", "") if "cc" in metadata else "", - ) + # content_container.update_header( + # subject=metadata.get("subject", "").strip(), + # from_=metadata["from"].get("addr", ""), + # to=metadata["to"].get("addr", ""), + # date=message_date, + # cc=metadata["cc"].get("addr", "") if "cc" in metadata else "", + # ) list_view = self.query_one("#envelopes_list") if list_view.index != metadata["index"]: @@ -391,47 +237,22 @@ class EmailViewerApp(App): msglist = self.query_one("#envelopes_list") try: msglist.loading = True - process = await asyncio.create_subprocess_shell( - "himalaya envelope list -o json -s 9999", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await process.communicate() - logging.info(f"stdout: {stdout.decode()[0:50]}") - if process.returncode == 0: - import json - envelopes = json.loads(stdout.decode()) - if envelopes: - self.reload_needed = False - self.message_store.load(envelopes, self.sort_order_ascending) - self.total_messages = self.message_store.total_messages - msglist.clear() + # Use the Himalaya client to fetch envelopes + envelopes, success = await himalaya_client.list_envelopes() - for item in self.message_store.envelopes: - if item.get("type") == "header": - msglist.append( - ListItem( - Label( - item["label"], - classes="group_header", - markup=False, - ) - ) - ) - else: - msglist.append( - ListItem( - Label( - str(item["subject"]).strip(), - classes="email_subject", - markup=False, - ) - ) - ) - msglist.index = self.current_message_index - else: - self.show_status("Failed to fetch any envelopes.", "error") + if success and envelopes: + self.reload_needed = False + self.message_store.load(envelopes, self.sort_order_ascending) + self.total_messages = self.message_store.total_messages + + # Use the centralized refresh method to update the ListView + self.refresh_list_view() + + # Restore the current index + msglist.index = self.current_message_index + else: + self.show_status("Failed to fetch envelopes.", "error") except Exception as e: self.show_status(f"Error fetching message list: {e}", "error") finally: @@ -442,27 +263,22 @@ class EmailViewerApp(App): accounts_list = self.query_one("#accounts_list") try: accounts_list.loading = True - process = await asyncio.create_subprocess_shell( - "himalaya account list -o json", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await process.communicate() - logging.info(f"stdout: {stdout.decode()[0:50]}") - if process.returncode == 0: - import json - accounts = json.loads(stdout.decode()) - if accounts: - for account in accounts: - item = ListItem( - Label( - str(account["name"]).strip(), - classes="account_name", - markup=False, - ) + # Use the Himalaya client to fetch accounts + accounts, success = await himalaya_client.list_accounts() + + if success and accounts: + for account in accounts: + item = ListItem( + Label( + str(account["name"]).strip(), + classes="account_name", + markup=False, ) - accounts_list.append(item) + ) + accounts_list.append(item) + else: + self.show_status("Failed to fetch accounts.", "error") except Exception as e: self.show_status(f"Error fetching account list: {e}", "error") finally: @@ -477,32 +293,57 @@ class EmailViewerApp(App): ) try: folders_list.loading = True - process = await asyncio.create_subprocess_shell( - "himalaya folder list -o json", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await process.communicate() - logging.info(f"stdout: {stdout.decode()[0:50]}") - if process.returncode == 0: - import json - folders = json.loads(stdout.decode()) - if folders: - for folder in folders: - item = ListItem( - Label( - str(folder["name"]).strip(), - classes="folder_name", - markup=False, - ) + # Use the Himalaya client to fetch folders + folders, success = await himalaya_client.list_folders() + + if success and folders: + for folder in folders: + item = ListItem( + Label( + str(folder["name"]).strip(), + classes="folder_name", + markup=False, ) - folders_list.append(item) + ) + folders_list.append(item) + else: + self.show_status("Failed to fetch folders.", "error") except Exception as e: self.show_status(f"Error fetching folder list: {e}", "error") finally: folders_list.loading = False + def refresh_list_view(self) -> None: + """Refresh the ListView to ensure it matches the MessageStore exactly.""" + envelopes_list = self.query_one("#envelopes_list") + envelopes_list.clear() + + for item in self.message_store.envelopes: + if item and item.get("type") == "header": + envelopes_list.append( + ListItem( + Label( + item["label"], + classes="group_header", + markup=False, + ) + ) + ) + elif item: # Check if not None + envelopes_list.append( + ListItem( + Label( + str(item.get("subject", "")).strip(), + classes="email_subject", + markup=False, + ) + ) + ) + + # Update total messages count + self.total_messages = self.message_store.total_messages + def show_message(self, message_id: int, new_index=None) -> None: if new_index: self.current_message_index = new_index @@ -553,16 +394,16 @@ class EmailViewerApp(App): self.fetch_envelopes() if self.reload_needed else None async def action_delete(self) -> None: - message_id_to_delete = self.current_message_id - self.message_store.remove(message_id_to_delete) - self.total_messages = self.message_store.total_messages - delete_current(self) + """Delete the current message and update UI consistently.""" + # Call the delete_current function which uses our Himalaya client module + worker = delete_current(self) + await worker.wait() async def action_archive(self) -> None: - message_id_to_archive = self.current_message_id - self.message_store.remove(message_id_to_archive) - self.total_messages = self.message_store.total_messages - archive_current(self) + """Archive the current message and update UI consistently.""" + # Call the archive_current function which uses our Himalaya client module + worker = archive_current(self) + await worker.wait() def action_open(self) -> None: action_open(self) diff --git a/maildir_gtd/email_viewer.tcss b/maildir_gtd/email_viewer.tcss index a77f62e..66ffae2 100644 --- a/maildir_gtd/email_viewer.tcss +++ b/maildir_gtd/email_viewer.tcss @@ -153,6 +153,12 @@ Label.group_header { width: 100%; } +#html_content { + padding: 1 2; + height: auto; + width: 100%; +} + .hidden { display: none; } diff --git a/maildir_gtd/message_store.py b/maildir_gtd/message_store.py new file mode 100644 index 0000000..9754a9a --- /dev/null +++ b/maildir_gtd/message_store.py @@ -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}") diff --git a/maildir_gtd/screens/CreateTask.py b/maildir_gtd/screens/CreateTask.py index 1a5d2c2..87ce968 100644 --- a/maildir_gtd/screens/CreateTask.py +++ b/maildir_gtd/screens/CreateTask.py @@ -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( - Horizontal( - Label("$>", id="task_prompt"), - Label("task add ", id="task_prompt_label"), - Input(placeholder="arguments", id="task_input"), - ), - Horizontal( - Button("Cancel", id="cancel"), - Button("Submit", id="submit", variant="primary"), + 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("Subject:"), + Input(placeholder="Task subject", value=self.subject, id="subject_input"), + ), + Horizontal( + 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": - self.dismiss() + @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 - def button_on_click(self, event): - if event.button.id == "cancel": + # 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() + + @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") diff --git a/maildir_gtd/utils.py b/maildir_gtd/utils.py index 0a965fe..8101cd8 100644 --- a/maildir_gtd/utils.py +++ b/maildir_gtd/utils.py @@ -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}) diff --git a/maildir_gtd/widgets/ContentContainer.py b/maildir_gtd/widgets/ContentContainer.py index 5d94d09..e7de65f 100644 --- a/maildir_gtd/widgets/ContentContainer.py +++ b/maildir_gtd/widgets/ContentContainer.py @@ -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() - else: - # Hide markdown, show plaintext - plaintext.remove_class("hidden") - self.query_one("#markdown_content").add_class("hidden") - - self.loading = False - return self.current_text + 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: - # Get message body (from cache or fetch new) - self.get_message_body(message_id) + self.current_mode = "html" + self.content.styles.display = "none" + self.html_content.styles.display = "block" + # 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 - try: - # Store the ID of the message we're currently loading - loading_id = message_id - - 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() - else: - # Hide markdown, show plaintext - plaintext.remove_class("hidden") - self.query_one("#markdown_content").add_class("hidden") - - 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") + content, success = await himalaya_client.get_message_content(message_id, format) + if success: + self._update_content(content) else: - # Switch to markdown - await self.render_markdown() - - return self.plaintext_mode + 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: + if self.current_mode == "text": + # For text mode, use the Markdown widget + + self.content.update(content) + else: + # 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'
]*>(.*?)', 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]').replace('', '[/b]') + content = content.replace('', '[i]').replace('', '[/i]') + content = content.replace('', '[u]').replace('', '[/u]') + + # Convert links to a readable format + content = re.sub(r']*>([^<]+)', r'[\2](\1)', content) + + # Add CSS for better readability + self.html_content.update(content) + + except Exception as e: + logging.error(f"Error updating content: {e}") + if self.current_mode == "text": + self.content.update(f"Error displaying content: {e}") + else: + self.html_content.update(f"Error displaying content: {e}") diff --git a/maildir_gtd/widgets/EnvelopeHeader.py b/maildir_gtd/widgets/EnvelopeHeader.py index 38a12f2..2d5049b 100644 --- a/maildir_gtd/widgets/EnvelopeHeader.py +++ b/maildir_gtd/widgets/EnvelopeHeader.py @@ -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.""" - self.query_one("#date").update(date) + """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.""" diff --git a/utils/mail_utils/helpers.py b/utils/mail_utils/helpers.py index 4414bf6..6bddbcd 100644 --- a/utils/mail_utils/helpers.py +++ b/utils/mail_utils/helpers.py @@ -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. diff --git a/utils/mail_utils/maildir.py b/utils/mail_utils/maildir.py index 42f89c5..435b464 100644 --- a/utils/mail_utils/maildir.py +++ b/utils/mail_utils/maildir.py @@ -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', '')