basically refactored the email viewer
This commit is contained in:
7
apis/__init__.py
Normal file
7
apis/__init__.py
Normal 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
21
apis/himalaya/__init__.py
Normal 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
169
apis/himalaya/client.py
Normal 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
|
||||||
@@ -59,6 +59,6 @@ def get_access_token(scopes):
|
|||||||
f.write(cache.serialize())
|
f.write(cache.serialize())
|
||||||
|
|
||||||
access_token = token_response['access_token']
|
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
|
return access_token, headers
|
||||||
|
|||||||
@@ -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.console.print(f"[DRY-RUN] Would delete message: {message_id}")
|
||||||
progress.advance(task_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.
|
Synchronize Maildir with Microsoft Graph API.
|
||||||
|
|
||||||
|
|||||||
17
apis/taskwarrior/__init__.py
Normal file
17
apis/taskwarrior/__init__.py
Normal 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
146
apis/taskwarrior/client.py
Normal 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
315
benchmark_list_update.py
Executable 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()
|
||||||
@@ -2,47 +2,41 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from textual import work
|
from textual import work
|
||||||
from textual.logging import TextualHandler
|
from apis.himalaya import client as himalaya_client
|
||||||
from textual.widgets import ListView
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level="NOTSET",
|
|
||||||
handlers=[TextualHandler()],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@work(exclusive=False)
|
@work(exclusive=True)
|
||||||
async def archive_current(app) -> None:
|
async def archive_current(app):
|
||||||
"""Archive the current email message."""
|
"""Archive the current message."""
|
||||||
try:
|
if not app.current_message_id:
|
||||||
index = app.current_message_index
|
app.show_status("No message selected to archive.", "error")
|
||||||
logging.info("Archiving message ID: " + str(app.current_message_id))
|
return
|
||||||
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)
|
|
||||||
|
|
||||||
# Find the next message to display using the MessageStore
|
# Store the current message ID and index
|
||||||
next_id, next_idx = app.message_store.find_next_valid_id(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
|
||||||
if next_id is not None and next_idx is not None:
|
next_id, next_idx = app.message_store.find_next_valid_id(current_index)
|
||||||
# Set ListView index first to ensure UI is synchronized
|
if next_id is None or next_idx is None:
|
||||||
app.query_one(ListView).index = next_idx
|
# If there's no next message, try to find a previous one
|
||||||
# Now update the current_message_id to trigger content update
|
next_id, next_idx = app.message_store.find_prev_valid_id(current_index)
|
||||||
app.current_message_id = next_id
|
|
||||||
else:
|
# Archive the message using our Himalaya client module
|
||||||
# No messages left, just update ListView
|
success = await himalaya_client.archive_message(current_message_id)
|
||||||
app.query_one(ListView).index = 0
|
|
||||||
app.reload_needed = True
|
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:
|
else:
|
||||||
app.show_status(f"Error archiving message: {stderr.decode()}", "error")
|
# If there are no other messages, reset the UI
|
||||||
except Exception as e:
|
app.current_message_id = 0
|
||||||
app.show_status(f"Error: {e}", "error")
|
app.show_status("No more messages available.", "warning")
|
||||||
|
else:
|
||||||
|
app.show_status(f"Failed to archive message {current_message_id}.", "error")
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from textual import work
|
from textual import work
|
||||||
from textual.widgets import ListView
|
from apis.himalaya import client as himalaya_client
|
||||||
|
|
||||||
|
|
||||||
@work(exclusive=False)
|
@work(exclusive=True)
|
||||||
async def delete_current(app) -> None:
|
async def delete_current(app):
|
||||||
app.show_status(f"Deleting message {app.current_message_id}...")
|
"""Delete the current message."""
|
||||||
try:
|
if not app.current_message_id:
|
||||||
index = app.current_message_index
|
app.show_status("No message selected to delete.", "error")
|
||||||
process = await asyncio.create_subprocess_shell(
|
return
|
||||||
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)
|
|
||||||
|
|
||||||
# Find the next message to display using the MessageStore
|
# Store the current message ID and index
|
||||||
next_id, next_idx = app.message_store.find_next_valid_id(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
|
||||||
if next_id is not None and next_idx is not None:
|
next_id, next_idx = app.message_store.find_next_valid_id(current_index)
|
||||||
# Set ListView index first to ensure UI is synchronized
|
if next_id is None or next_idx is None:
|
||||||
app.query_one(ListView).index = next_idx
|
# If there's no next message, try to find a previous one
|
||||||
# Now update the current_message_id to trigger content update
|
next_id, next_idx = app.message_store.find_prev_valid_id(current_index)
|
||||||
app.current_message_id = next_id
|
|
||||||
else:
|
# Delete the message using our Himalaya client module
|
||||||
# No messages left, just update ListView
|
success = await himalaya_client.delete_message(current_message_id)
|
||||||
app.query_one(ListView).index = 0
|
|
||||||
app.reload_needed = True
|
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:
|
else:
|
||||||
app.show_status(
|
# If there are no other messages, reset the UI
|
||||||
f"Failed to delete message {app.current_message_id}. {stderr.decode()}",
|
app.current_message_id = 0
|
||||||
"error",
|
app.show_status("No more messages available.", "warning")
|
||||||
)
|
else:
|
||||||
except Exception as e:
|
app.show_status(f"Failed to delete message {current_message_id}.", "error")
|
||||||
app.show_status(f"Error: {e}", "error")
|
|
||||||
|
|||||||
@@ -1,28 +1,49 @@
|
|||||||
import asyncio
|
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
|
from maildir_gtd.screens.CreateTask import CreateTaskScreen
|
||||||
|
|
||||||
|
class TaskAction:
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
def action_create_task(app) -> None:
|
def action_create_task(app):
|
||||||
"""Show the input modal for creating a task."""
|
"""Show the create task screen."""
|
||||||
|
|
||||||
async def check_task(task_args: str) -> bool:
|
current_message_id = app.current_message_id
|
||||||
try:
|
if not current_message_id:
|
||||||
result = await asyncio.create_subprocess_shell(
|
app.show_status("No message selected to create task from.", "error")
|
||||||
f"task add {task_args}",
|
return
|
||||||
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
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Iterable, Optional, List, Dict, Any, Generator, Tuple
|
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
|
# 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.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 import work
|
||||||
from textual.worker import Worker
|
from textual.worker import Worker
|
||||||
from textual.app import App, ComposeResult, SystemCommand, RenderResult
|
from textual.app import App, ComposeResult, SystemCommand, RenderResult
|
||||||
@@ -23,175 +21,25 @@ from textual.binding import Binding
|
|||||||
from textual.timer import Timer
|
from textual.timer import Timer
|
||||||
from textual.containers import ScrollableContainer, Vertical, Horizontal
|
from textual.containers import ScrollableContainer, Vertical, Horizontal
|
||||||
|
|
||||||
from actions.archive import archive_current
|
# Import our new API modules
|
||||||
from actions.delete import delete_current
|
from apis.himalaya import client as himalaya_client
|
||||||
from actions.open import action_open
|
from apis.taskwarrior import client as taskwarrior_client
|
||||||
from actions.task import action_create_task
|
|
||||||
from widgets.EnvelopeHeader import EnvelopeHeader
|
# Updated imports with correct relative paths
|
||||||
from widgets.ContentContainer import ContentContainer
|
from maildir_gtd.actions.archive import archive_current
|
||||||
from maildir_gtd.utils import group_envelopes_by_date
|
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(
|
logging.basicConfig(
|
||||||
level="NOTSET",
|
level="NOTSET",
|
||||||
handlers=[TextualHandler()],
|
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):
|
class StatusTitle(Static):
|
||||||
total_messages: Reactive[int] = reactive(0)
|
total_messages: Reactive[int] = reactive(0)
|
||||||
current_message_index: Reactive[int] = reactive(0)
|
current_message_index: Reactive[int] = reactive(0)
|
||||||
@@ -293,11 +141,11 @@ class EmailViewerApp(App):
|
|||||||
self.theme = "monokai"
|
self.theme = "monokai"
|
||||||
self.title = "MaildirGTD"
|
self.title = "MaildirGTD"
|
||||||
self.query_one("#main_content").border_title = self.status_title
|
self.query_one("#main_content").border_title = self.status_title
|
||||||
sort_indicator = "\u2191" if self.sort_order_ascending else "\u2193"
|
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
||||||
self.query_one("#envelopes_list").border_title = f"\[1] Emails {sort_indicator}"
|
self.query_one("#envelopes_list").border_title = f"1️⃣ Emails {sort_indicator}"
|
||||||
self.query_one("#accounts_list").border_title = "\[2] Accounts"
|
self.query_one("#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_accounts()
|
||||||
self.fetch_folders()
|
self.fetch_folders()
|
||||||
@@ -314,8 +162,8 @@ class EmailViewerApp(App):
|
|||||||
|
|
||||||
def watch_sort_order_ascending(self, old_value: bool, new_value: bool) -> None:
|
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."""
|
"""Update the border title of the envelopes list when the sort order changes."""
|
||||||
sort_indicator = "\u2191" if new_value else "\u2193"
|
sort_indicator = "↑" if new_value else "↓"
|
||||||
self.query_one("#envelopes_list").border_title = f"\[1] Emails {sort_indicator}"
|
self.query_one("#envelopes_list").border_title = f"1️⃣ Emails {sort_indicator}"
|
||||||
|
|
||||||
def watch_current_message_index(self, old_index: int, new_index: int) -> None:
|
def watch_current_message_index(self, old_index: int, new_index: int) -> None:
|
||||||
if new_index < 0:
|
if new_index < 0:
|
||||||
@@ -353,21 +201,19 @@ class EmailViewerApp(App):
|
|||||||
|
|
||||||
metadata = self.message_store.get_metadata(new_message_id)
|
metadata = self.message_store.get_metadata(new_message_id)
|
||||||
if metadata:
|
if metadata:
|
||||||
message_date = re.sub(r"[\+\-]\d\d:\d\d", "", metadata["date"])
|
# Pass the complete date string with timezone information
|
||||||
message_date = datetime.strptime(message_date, "%Y-%m-%d %H:%M").strftime(
|
message_date = metadata["date"]
|
||||||
"%a %b %d %H:%M"
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.current_message_index != metadata["index"]:
|
if self.current_message_index != metadata["index"]:
|
||||||
self.current_message_index = metadata["index"]
|
self.current_message_index = metadata["index"]
|
||||||
|
|
||||||
content_container.update_header(
|
# content_container.update_header(
|
||||||
subject=metadata.get("subject", "").strip(),
|
# subject=metadata.get("subject", "").strip(),
|
||||||
from_=metadata["from"].get("addr", ""),
|
# from_=metadata["from"].get("addr", ""),
|
||||||
to=metadata["to"].get("addr", ""),
|
# to=metadata["to"].get("addr", ""),
|
||||||
date=message_date,
|
# date=message_date,
|
||||||
cc=metadata["cc"].get("addr", "") if "cc" in metadata else "",
|
# cc=metadata["cc"].get("addr", "") if "cc" in metadata else "",
|
||||||
)
|
# )
|
||||||
|
|
||||||
list_view = self.query_one("#envelopes_list")
|
list_view = self.query_one("#envelopes_list")
|
||||||
if list_view.index != metadata["index"]:
|
if list_view.index != metadata["index"]:
|
||||||
@@ -391,47 +237,22 @@ class EmailViewerApp(App):
|
|||||||
msglist = self.query_one("#envelopes_list")
|
msglist = self.query_one("#envelopes_list")
|
||||||
try:
|
try:
|
||||||
msglist.loading = True
|
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())
|
# Use the Himalaya client to fetch envelopes
|
||||||
if envelopes:
|
envelopes, success = await himalaya_client.list_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 success and envelopes:
|
||||||
if item.get("type") == "header":
|
self.reload_needed = False
|
||||||
msglist.append(
|
self.message_store.load(envelopes, self.sort_order_ascending)
|
||||||
ListItem(
|
self.total_messages = self.message_store.total_messages
|
||||||
Label(
|
|
||||||
item["label"],
|
# Use the centralized refresh method to update the ListView
|
||||||
classes="group_header",
|
self.refresh_list_view()
|
||||||
markup=False,
|
|
||||||
)
|
# Restore the current index
|
||||||
)
|
msglist.index = self.current_message_index
|
||||||
)
|
else:
|
||||||
else:
|
self.show_status("Failed to fetch envelopes.", "error")
|
||||||
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")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.show_status(f"Error fetching message list: {e}", "error")
|
self.show_status(f"Error fetching message list: {e}", "error")
|
||||||
finally:
|
finally:
|
||||||
@@ -442,27 +263,22 @@ class EmailViewerApp(App):
|
|||||||
accounts_list = self.query_one("#accounts_list")
|
accounts_list = self.query_one("#accounts_list")
|
||||||
try:
|
try:
|
||||||
accounts_list.loading = True
|
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())
|
# Use the Himalaya client to fetch accounts
|
||||||
if accounts:
|
accounts, success = await himalaya_client.list_accounts()
|
||||||
for account in accounts:
|
|
||||||
item = ListItem(
|
if success and accounts:
|
||||||
Label(
|
for account in accounts:
|
||||||
str(account["name"]).strip(),
|
item = ListItem(
|
||||||
classes="account_name",
|
Label(
|
||||||
markup=False,
|
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:
|
except Exception as e:
|
||||||
self.show_status(f"Error fetching account list: {e}", "error")
|
self.show_status(f"Error fetching account list: {e}", "error")
|
||||||
finally:
|
finally:
|
||||||
@@ -477,32 +293,57 @@ class EmailViewerApp(App):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
folders_list.loading = True
|
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())
|
# Use the Himalaya client to fetch folders
|
||||||
if folders:
|
folders, success = await himalaya_client.list_folders()
|
||||||
for folder in folders:
|
|
||||||
item = ListItem(
|
if success and folders:
|
||||||
Label(
|
for folder in folders:
|
||||||
str(folder["name"]).strip(),
|
item = ListItem(
|
||||||
classes="folder_name",
|
Label(
|
||||||
markup=False,
|
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:
|
except Exception as e:
|
||||||
self.show_status(f"Error fetching folder list: {e}", "error")
|
self.show_status(f"Error fetching folder list: {e}", "error")
|
||||||
finally:
|
finally:
|
||||||
folders_list.loading = False
|
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:
|
def show_message(self, message_id: int, new_index=None) -> None:
|
||||||
if new_index:
|
if new_index:
|
||||||
self.current_message_index = new_index
|
self.current_message_index = new_index
|
||||||
@@ -553,16 +394,16 @@ class EmailViewerApp(App):
|
|||||||
self.fetch_envelopes() if self.reload_needed else None
|
self.fetch_envelopes() if self.reload_needed else None
|
||||||
|
|
||||||
async def action_delete(self) -> None:
|
async def action_delete(self) -> None:
|
||||||
message_id_to_delete = self.current_message_id
|
"""Delete the current message and update UI consistently."""
|
||||||
self.message_store.remove(message_id_to_delete)
|
# Call the delete_current function which uses our Himalaya client module
|
||||||
self.total_messages = self.message_store.total_messages
|
worker = delete_current(self)
|
||||||
delete_current(self)
|
await worker.wait()
|
||||||
|
|
||||||
async def action_archive(self) -> None:
|
async def action_archive(self) -> None:
|
||||||
message_id_to_archive = self.current_message_id
|
"""Archive the current message and update UI consistently."""
|
||||||
self.message_store.remove(message_id_to_archive)
|
# Call the archive_current function which uses our Himalaya client module
|
||||||
self.total_messages = self.message_store.total_messages
|
worker = archive_current(self)
|
||||||
archive_current(self)
|
await worker.wait()
|
||||||
|
|
||||||
def action_open(self) -> None:
|
def action_open(self) -> None:
|
||||||
action_open(self)
|
action_open(self)
|
||||||
|
|||||||
@@ -153,6 +153,12 @@ Label.group_header {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#html_content {
|
||||||
|
padding: 1 2;
|
||||||
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
143
maildir_gtd/message_store.py
Normal file
143
maildir_gtd/message_store.py
Normal 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}")
|
||||||
@@ -1,43 +1,105 @@
|
|||||||
from textual import on
|
import logging
|
||||||
from textual.app import ComposeResult
|
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Input, Label, Button
|
from textual.widgets import Input, Label, Button, ListView, ListItem
|
||||||
from textual.containers import Horizontal, Vertical
|
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 __init__(self, subject="", from_addr="", **kwargs):
|
||||||
def compose(self) -> ComposeResult:
|
super().__init__(**kwargs)
|
||||||
yield Vertical(
|
self.subject = subject
|
||||||
Horizontal(
|
self.from_addr = from_addr
|
||||||
Label("$>", id="task_prompt"),
|
self.selected_project = None
|
||||||
Label("task add ", id="task_prompt_label"),
|
|
||||||
Input(placeholder="arguments", id="task_input"),
|
def compose(self):
|
||||||
),
|
yield Container(
|
||||||
Horizontal(
|
Vertical(
|
||||||
Button("Cancel", id="cancel"),
|
Label("Create Task", id="create_task_title"),
|
||||||
Button("Submit", id="submit", variant="primary"),
|
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",
|
id="create_task_container",
|
||||||
classes="modal_screen",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@on(Input.Submitted)
|
def on_mount(self):
|
||||||
def handle_task_args(self) -> None:
|
self.styles.align = ("center", "middle")
|
||||||
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_key(self, event) -> None:
|
@on(Button.Pressed, "#create_btn")
|
||||||
if event.key == "escape" or event.key == "ctrl+c":
|
def on_create_pressed(self):
|
||||||
self.dismiss()
|
"""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):
|
# Process tags (split by commas and trim whitespace)
|
||||||
if event.button.id == "cancel":
|
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()
|
self.dismiss()
|
||||||
elif event.button.id == "submit":
|
else:
|
||||||
input_widget = self.query_one("#task_input", Input)
|
self.app.show_status(f"Failed to create task: {result}", "error")
|
||||||
task_args = input_widget.value
|
|
||||||
self.dismiss(task_args)
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
import re
|
import re
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ from typing import List, Dict
|
|||||||
def group_envelopes_by_date(envelopes: List[Dict]) -> List[Dict]:
|
def group_envelopes_by_date(envelopes: List[Dict]) -> List[Dict]:
|
||||||
"""Group envelopes by date and add headers for each group."""
|
"""Group envelopes by date and add headers for each group."""
|
||||||
grouped_envelopes = []
|
grouped_envelopes = []
|
||||||
today = datetime.now()
|
today = datetime.now().astimezone(UTC)
|
||||||
yesterday = today - timedelta(days=1)
|
yesterday = today - timedelta(days=1)
|
||||||
start_of_week = today - timedelta(days=today.weekday())
|
start_of_week = today - timedelta(days=today.weekday())
|
||||||
start_of_last_week = start_of_week - timedelta(weeks=1)
|
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
|
current_group = None
|
||||||
for envelope in envelopes:
|
for envelope in envelopes:
|
||||||
envelope_date = re.sub(r"[\+\-]\d\d:\d\d", "", envelope["date"])
|
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)
|
group_label = get_group_label(envelope_date)
|
||||||
if group_label != current_group:
|
if group_label != current_group:
|
||||||
grouped_envelopes.append({"type": "header", "label": group_label})
|
grouped_envelopes.append({"type": "header", "label": group_label})
|
||||||
|
|||||||
@@ -1,186 +1,154 @@
|
|||||||
import re
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
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 import work
|
||||||
from textual.app import ComposeResult
|
from textual.worker import Worker
|
||||||
from textual.widgets import Label, Markdown
|
from apis.himalaya import client as himalaya_client
|
||||||
from textual.containers import ScrollableContainer
|
|
||||||
|
|
||||||
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):
|
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:
|
async def toggle_mode(self):
|
||||||
"""Compose the container with a label for plaintext and markdown for rich content."""
|
"""Toggle between plaintext and HTML viewing modes."""
|
||||||
yield EnvelopeHeader()
|
if self.current_mode == "html":
|
||||||
yield Label(id="plaintext_content", markup=False)
|
self.current_mode = "text"
|
||||||
yield Markdown(id="markdown_content", classes="hidden")
|
self.html_content.styles.display = "none"
|
||||||
|
self.content.styles.display = "block"
|
||||||
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
|
|
||||||
else:
|
else:
|
||||||
# Get message body (from cache or fetch new)
|
self.current_mode = "html"
|
||||||
self.get_message_body(message_id)
|
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)
|
@work(exclusive=True)
|
||||||
async def get_message_body(self, message_id: int) -> str:
|
async def fetch_message_content(self, message_id: int, format: str):
|
||||||
"""Fetch the message body from Himalaya CLI."""
|
"""Fetch message content using the Himalaya client module."""
|
||||||
|
if not message_id:
|
||||||
|
self.notify("No message ID provided.")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
content, success = await himalaya_client.get_message_content(message_id, format)
|
||||||
# Store the ID of the message we're currently loading
|
if success:
|
||||||
loading_id = message_id
|
self._update_content(content)
|
||||||
|
|
||||||
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")
|
|
||||||
else:
|
else:
|
||||||
# Switch to markdown
|
self.notify(f"Failed to fetch content for message ID {message_id}.")
|
||||||
await self.render_markdown()
|
|
||||||
|
|
||||||
return self.plaintext_mode
|
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'<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)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ from textual.reactive import Reactive
|
|||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.widgets import Label
|
from textual.widgets import Label
|
||||||
from textual.containers import Horizontal, ScrollableContainer
|
from textual.containers import Horizontal, ScrollableContainer
|
||||||
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
|
|
||||||
class EnvelopeHeader(ScrollableContainer):
|
class EnvelopeHeader(ScrollableContainer):
|
||||||
@@ -55,8 +58,49 @@ class EnvelopeHeader(ScrollableContainer):
|
|||||||
# self.query_one("#from").update(from_)
|
# self.query_one("#from").update(from_)
|
||||||
|
|
||||||
def watch_date(self, date: str) -> None:
|
def watch_date(self, date: str) -> None:
|
||||||
"""Watch the date for changes."""
|
"""Watch the date for changes and convert to local timezone."""
|
||||||
self.query_one("#date").update(date)
|
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:
|
# def watch_cc(self, cc: str) -> None:
|
||||||
# """Watch the cc field for changes."""
|
# """Watch the cc field for changes."""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import email.utils
|
||||||
|
|
||||||
def truncate_id(message_id, length=8):
|
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):
|
except (ValueError, AttributeError):
|
||||||
return dt_str
|
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):
|
def safe_filename(filename):
|
||||||
"""
|
"""
|
||||||
Convert a string to a safe filename.
|
Convert a string to a safe filename.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import aiohttp
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from utils.calendar_utils import truncate_id
|
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):
|
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]
|
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)
|
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', '')
|
received_datetime = message.get('receivedDateTime', '')
|
||||||
if received_datetime:
|
if received_datetime:
|
||||||
mime_msg['Date'] = received_datetime
|
mime_msg['Date'] = format_mime_date(received_datetime)
|
||||||
|
|
||||||
# First try the direct body content approach
|
# First try the direct body content approach
|
||||||
message_id = message.get('id', '')
|
message_id = message.get('id', '')
|
||||||
|
|||||||
Reference in New Issue
Block a user