Compare commits
32 Commits
848e2a43a6
...
d6e10e3dc5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6e10e3dc5 | ||
|
|
ab6e080bb4 | ||
|
|
44cfe3f714 | ||
|
|
19bc1c7832 | ||
|
|
c5202793d4 | ||
|
|
95d3098bf3 | ||
|
|
599507068a | ||
|
|
505fdbcd3d | ||
|
|
1337d84369 | ||
|
|
f1ec6c23e1 | ||
|
|
4836bda9f9 | ||
|
|
9f596b10ae | ||
|
|
98c318af04 | ||
|
|
994e545bd0 | ||
|
|
fb0af600a1 | ||
|
|
39a5efbb81 | ||
|
|
b903832d17 | ||
|
|
8233829621 | ||
|
|
36a1ea7c47 | ||
|
|
4e859613f9 | ||
|
|
b9d818ac09 | ||
|
|
ab55d0836e | ||
|
|
f5ad43323c | ||
|
|
8933dadcd0 | ||
|
|
aaabd83fc7 | ||
|
|
560bc1d3bd | ||
|
|
d4b09e5338 | ||
|
|
9a2f8ee211 | ||
|
|
5deebbbf98 | ||
|
|
807736f808 | ||
|
|
a5f7e78d8d | ||
|
|
f56f1931bf |
@@ -439,26 +439,26 @@ Implement `/` keybinding for search across all apps with similar UX:
|
||||
|
||||
### Phase 1: Critical/High Priority
|
||||
1. ~~Tasks App: Fix table display~~ (DONE)
|
||||
2. Sync: Parallelize message downloads
|
||||
3. Mail: Replace hardcoded RGB colors
|
||||
4. Mail: Remove envelope icon/checkbox gap
|
||||
5. Calendar: Current time hour line styling
|
||||
6. IPC: Implement cross-app refresh notifications
|
||||
2. ~~Sync: Parallelize message downloads~~ (DONE - connection pooling + batch size increase)
|
||||
3. ~~Mail: Replace hardcoded RGB colors~~ (DONE - already using theme variables)
|
||||
4. ~~Mail: Remove envelope icon/checkbox gap~~ (DONE)
|
||||
5. ~~Calendar: Current time hour line styling~~ (DONE - added surface background)
|
||||
6. ~~IPC: Implement cross-app refresh notifications~~ (DONE)
|
||||
|
||||
### Phase 2: Medium Priority
|
||||
1. Sync: Default to TUI mode
|
||||
2. Calendar: Cursor hour header highlighting
|
||||
1. ~~Sync: Default to TUI mode~~ (DONE - already implemented)
|
||||
2. ~~Calendar: Cursor hour header highlighting~~ (DONE)
|
||||
3. Calendar: Responsive detail panel
|
||||
4. Calendar: Sidebar mini-calendar
|
||||
5. Calendar: Calendar invites sidebar
|
||||
6. Mail: Add refresh keybinding
|
||||
7. Mail: Add mark read/unread action
|
||||
8. Mail: Folder message counts
|
||||
9. Mail: URL compression in markdown view
|
||||
10. Mail: Enhance subject styling
|
||||
11. Mail: Search feature
|
||||
12. Tasks: Search feature
|
||||
13. Calendar: Search feature
|
||||
6. ~~Mail: Add refresh keybinding~~ (DONE - `r` key)
|
||||
7. ~~Mail: Add mark read/unread action~~ (DONE - `u` key)
|
||||
8. ~~Mail: Folder message counts~~ (DONE)
|
||||
8. ~~Mail: URL compression in markdown view~~ (DONE)
|
||||
9. ~~Mail: Enhance subject styling~~ (DONE)
|
||||
10. Mail: Search feature
|
||||
11. ~~Tasks: Search feature~~ (DONE - `/` key with live filtering)
|
||||
12. ~~Calendar: Search feature~~ (DONE - `/` key using khal search)
|
||||
|
||||
### Phase 3: Low Priority
|
||||
1. Sync: UI consistency (j/k navigation, borders)
|
||||
|
||||
@@ -13,7 +13,7 @@ from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.logging import TextualHandler
|
||||
from textual.widgets import Footer, Header, Static
|
||||
from textual.widgets import Footer, Header, Static, Input
|
||||
from textual.reactive import reactive
|
||||
|
||||
from src.calendar.backend import CalendarBackend, Event
|
||||
@@ -22,6 +22,7 @@ from src.calendar.widgets.MonthCalendar import MonthCalendar
|
||||
from src.calendar.widgets.InvitesPanel import InvitesPanel, CalendarInvite
|
||||
from src.calendar.widgets.AddEventForm import EventFormData
|
||||
from src.utils.shared_config import get_theme_name
|
||||
from src.utils.ipc import IPCListener, IPCMessage
|
||||
|
||||
# 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__))))
|
||||
@@ -115,6 +116,42 @@ class CalendarApp(App):
|
||||
#event-detail.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#search-container {
|
||||
dock: top;
|
||||
height: 4;
|
||||
width: 100%;
|
||||
background: $surface;
|
||||
border-bottom: solid $primary;
|
||||
padding: 0 1;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
#search-container.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#search-container .search-label {
|
||||
width: auto;
|
||||
padding: 0 1;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
#search-results {
|
||||
dock: bottom;
|
||||
height: 40%;
|
||||
border-top: solid $primary;
|
||||
background: $surface;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#search-results.hidden {
|
||||
display: none;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
@@ -132,6 +169,8 @@ class CalendarApp(App):
|
||||
Binding("r", "refresh", "Refresh", show=True),
|
||||
Binding("enter", "view_event", "View", show=True),
|
||||
Binding("a", "add_event", "Add", show=True),
|
||||
Binding("slash", "search", "Search", show=True),
|
||||
Binding("escape", "clear_search", "Clear Search", show=False),
|
||||
Binding("?", "help", "Help", show=True),
|
||||
]
|
||||
|
||||
@@ -142,10 +181,12 @@ class CalendarApp(App):
|
||||
# Instance attributes
|
||||
backend: Optional[CalendarBackend]
|
||||
_invites: list[CalendarInvite]
|
||||
_search_results: list[Event]
|
||||
|
||||
def __init__(self, backend: Optional[CalendarBackend] = None):
|
||||
super().__init__()
|
||||
self._invites = []
|
||||
self._search_results = []
|
||||
|
||||
if backend:
|
||||
self.backend = backend
|
||||
@@ -158,11 +199,18 @@ class CalendarApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create the app layout."""
|
||||
yield Header()
|
||||
yield Horizontal(
|
||||
Static("\uf002 Search:", classes="search-label"), # nf-fa-search
|
||||
Input(placeholder="Search events...", id="search-input", disabled=True),
|
||||
id="search-container",
|
||||
classes="hidden",
|
||||
)
|
||||
with Horizontal(id="main-content"):
|
||||
with Vertical(id="sidebar"):
|
||||
yield MonthCalendar(id="sidebar-calendar")
|
||||
yield InvitesPanel(id="sidebar-invites")
|
||||
yield WeekGrid(id="week-grid")
|
||||
yield Static(id="search-results", classes="hidden")
|
||||
yield Static(id="event-detail", classes="hidden")
|
||||
yield CalendarStatusBar(id="status-bar")
|
||||
yield Footer()
|
||||
@@ -171,6 +219,10 @@ class CalendarApp(App):
|
||||
"""Initialize the app on mount."""
|
||||
self.theme = get_theme_name()
|
||||
|
||||
# Start IPC listener for refresh notifications from sync daemon
|
||||
self._ipc_listener = IPCListener("calendar", self._on_ipc_message)
|
||||
self._ipc_listener.start()
|
||||
|
||||
# Load events for current week
|
||||
self.load_events()
|
||||
|
||||
@@ -184,6 +236,15 @@ class CalendarApp(App):
|
||||
self._update_status()
|
||||
self._update_title()
|
||||
|
||||
# Focus the week grid (not the hidden search input)
|
||||
self.query_one("#week-grid", WeekGrid).focus()
|
||||
|
||||
def _on_ipc_message(self, message: IPCMessage) -> None:
|
||||
"""Handle IPC messages from sync daemon."""
|
||||
if message.event == "refresh":
|
||||
# Schedule a reload on the main thread
|
||||
self.call_from_thread(self.load_events)
|
||||
|
||||
async def _load_invites_async(self) -> None:
|
||||
"""Load pending calendar invites from Microsoft Graph."""
|
||||
try:
|
||||
@@ -521,6 +582,8 @@ Keybindings:
|
||||
w - Toggle weekends (5/7 days)
|
||||
s - Toggle sidebar
|
||||
i - Focus invites panel
|
||||
/ - Search events
|
||||
Esc - Clear search
|
||||
Enter - View event details
|
||||
a - Add new event
|
||||
r - Refresh
|
||||
@@ -528,6 +591,90 @@ Keybindings:
|
||||
"""
|
||||
self.notify(help_text.strip(), timeout=10)
|
||||
|
||||
# Search actions
|
||||
def action_search(self) -> None:
|
||||
"""Show search input and focus it."""
|
||||
search_container = self.query_one("#search-container")
|
||||
search_container.remove_class("hidden")
|
||||
search_input = self.query_one("#search-input", Input)
|
||||
search_input.disabled = False
|
||||
search_input.focus()
|
||||
|
||||
def action_clear_search(self) -> None:
|
||||
"""Clear search and hide search UI."""
|
||||
search_container = self.query_one("#search-container")
|
||||
search_results = self.query_one("#search-results", Static)
|
||||
search_input = self.query_one("#search-input", Input)
|
||||
|
||||
# Only act if search is visible
|
||||
if not search_container.has_class("hidden") or not search_results.has_class(
|
||||
"hidden"
|
||||
):
|
||||
search_input.value = ""
|
||||
search_input.disabled = True
|
||||
search_container.add_class("hidden")
|
||||
search_results.add_class("hidden")
|
||||
self._search_results = []
|
||||
# Focus back to grid
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle Enter in search input - perform search."""
|
||||
if event.input.id != "search-input":
|
||||
return
|
||||
|
||||
query = event.value.strip()
|
||||
if not query:
|
||||
return
|
||||
|
||||
self._perform_search(query)
|
||||
|
||||
def _perform_search(self, query: str) -> None:
|
||||
"""Perform event search and display results."""
|
||||
if not self.backend:
|
||||
return
|
||||
|
||||
# Check if backend has search_events method
|
||||
if not hasattr(self.backend, "search_events"):
|
||||
self.notify(
|
||||
"Search not supported by this calendar backend", severity="warning"
|
||||
)
|
||||
return
|
||||
|
||||
results = self.backend.search_events(query)
|
||||
self._search_results = results
|
||||
|
||||
# Update results display
|
||||
search_results = self.query_one("#search-results", Static)
|
||||
|
||||
if results:
|
||||
lines = [f"[b]Search results for '{query}': {len(results)} found[/b]", ""]
|
||||
for event in results[:20]: # Limit display to 20 results
|
||||
date_str = event.start.strftime("%Y-%m-%d %H:%M")
|
||||
lines.append(f" {date_str} [b]{event.title}[/b]")
|
||||
if event.location:
|
||||
lines.append(f" [dim]{event.location}[/dim]")
|
||||
if len(results) > 20:
|
||||
lines.append(f" ... and {len(results) - 20} more")
|
||||
search_results.update("\n".join(lines))
|
||||
search_results.remove_class("hidden")
|
||||
self.notify(f"Found {len(results)} event(s)")
|
||||
else:
|
||||
search_results.update(f"[b]No events found matching '{query}'[/b]")
|
||||
search_results.remove_class("hidden")
|
||||
self.notify("No events found")
|
||||
|
||||
# Focus back to grid
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.focus()
|
||||
|
||||
async def action_quit(self) -> None:
|
||||
"""Quit the app and clean up IPC listener."""
|
||||
if hasattr(self, "_ipc_listener"):
|
||||
self._ipc_listener.stop()
|
||||
self.exit()
|
||||
|
||||
def action_focus_invites(self) -> None:
|
||||
"""Focus on the invites panel and show invite count."""
|
||||
if not self.show_sidebar:
|
||||
|
||||
@@ -216,3 +216,17 @@ class CalendarBackend(ABC):
|
||||
by_date[d].sort(key=lambda e: e.start)
|
||||
|
||||
return by_date
|
||||
|
||||
def search_events(self, query: str) -> List[Event]:
|
||||
"""Search for events matching a query string.
|
||||
|
||||
Default implementation returns empty list. Override in subclasses
|
||||
that support search.
|
||||
|
||||
Args:
|
||||
query: Search string to match against event titles and descriptions
|
||||
|
||||
Returns:
|
||||
List of matching events
|
||||
"""
|
||||
return []
|
||||
|
||||
@@ -364,6 +364,9 @@ class WeekGridBody(ScrollView):
|
||||
current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row)
|
||||
is_current_time_row = row_index == current_row
|
||||
|
||||
# Check if cursor is on this row
|
||||
is_cursor_row = row_index == self.cursor_row
|
||||
|
||||
# Time label (only show on the hour)
|
||||
if row_index % rows_per_hour == 0:
|
||||
hour = row_index // rows_per_hour
|
||||
@@ -371,10 +374,16 @@ class WeekGridBody(ScrollView):
|
||||
else:
|
||||
time_str = " " # Blank for half-hour
|
||||
|
||||
# Style time label - highlight current time, dim outside work hours
|
||||
if is_current_time_row:
|
||||
# Style time label - highlight current time or cursor, dim outside work hours
|
||||
if is_cursor_row:
|
||||
# Highlight the hour label when cursor is on this row
|
||||
primary_color = self._get_theme_color("primary")
|
||||
time_style = Style(color=primary_color, bold=True, reverse=True)
|
||||
elif is_current_time_row:
|
||||
error_color = self._get_theme_color("error")
|
||||
time_style = Style(color=error_color, bold=True)
|
||||
# Add subtle background to current time row for better visibility
|
||||
surface_color = self._get_theme_color("surface")
|
||||
time_style = Style(color=error_color, bold=True, bgcolor=surface_color)
|
||||
elif (
|
||||
row_index < self._work_day_start * rows_per_hour
|
||||
or row_index >= self._work_day_end * rows_per_hour
|
||||
|
||||
@@ -715,6 +715,24 @@ def sync(
|
||||
else:
|
||||
# Default: Launch interactive TUI dashboard
|
||||
from .sync_dashboard import run_dashboard_sync
|
||||
from src.services.microsoft_graph.auth import has_valid_cached_token
|
||||
|
||||
# Check if we need to authenticate before starting the TUI
|
||||
# This prevents the TUI from appearing to freeze during device flow auth
|
||||
if not demo:
|
||||
scopes = [
|
||||
"https://graph.microsoft.com/Calendars.Read",
|
||||
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||
]
|
||||
if not has_valid_cached_token(scopes):
|
||||
click.echo("Authentication required. Please complete the login flow...")
|
||||
try:
|
||||
# This will trigger the device flow auth in the console
|
||||
get_access_token(scopes)
|
||||
click.echo("Authentication successful! Starting dashboard...")
|
||||
except Exception as e:
|
||||
click.echo(f"Authentication failed: {e}")
|
||||
return
|
||||
|
||||
sync_config = {
|
||||
"org": org,
|
||||
@@ -936,6 +954,27 @@ def status():
|
||||
def interactive(org, vdir, notify, dry_run, demo):
|
||||
"""Launch interactive TUI dashboard for sync operations."""
|
||||
from .sync_dashboard import run_dashboard_sync
|
||||
from src.services.microsoft_graph.auth import (
|
||||
has_valid_cached_token,
|
||||
get_access_token,
|
||||
)
|
||||
|
||||
# Check if we need to authenticate before starting the TUI
|
||||
# This prevents the TUI from appearing to freeze during device flow auth
|
||||
if not demo:
|
||||
scopes = [
|
||||
"https://graph.microsoft.com/Calendars.Read",
|
||||
"https://graph.microsoft.com/Mail.ReadWrite",
|
||||
]
|
||||
if not has_valid_cached_token(scopes):
|
||||
click.echo("Authentication required. Please complete the login flow...")
|
||||
try:
|
||||
# This will trigger the device flow auth in the console
|
||||
get_access_token(scopes)
|
||||
click.echo("Authentication successful! Starting dashboard...")
|
||||
except Exception as e:
|
||||
click.echo(f"Authentication failed: {e}")
|
||||
return
|
||||
|
||||
sync_config = {
|
||||
"org": org,
|
||||
|
||||
@@ -1038,6 +1038,11 @@ async def run_dashboard_sync(
|
||||
# Schedule next sync
|
||||
dashboard.schedule_next_sync()
|
||||
|
||||
# Notify all running TUI apps to refresh their data
|
||||
from src.utils.ipc import notify_all
|
||||
|
||||
await notify_all({"source": "sync_dashboard_demo"})
|
||||
|
||||
except Exception as e:
|
||||
tracker.error_task("archive", str(e))
|
||||
|
||||
@@ -1070,6 +1075,7 @@ async def run_dashboard_sync(
|
||||
)
|
||||
from src.utils.calendar_utils import save_events_to_vdir, save_events_to_file
|
||||
from src.utils.notifications import notify_new_emails
|
||||
from src.utils.ipc import notify_all
|
||||
|
||||
config = dashboard._sync_config
|
||||
|
||||
@@ -1372,6 +1378,9 @@ async def run_dashboard_sync(
|
||||
# Schedule next sync
|
||||
dashboard.schedule_next_sync()
|
||||
|
||||
# Notify all running TUI apps to refresh their data
|
||||
await notify_all({"source": "sync_dashboard"})
|
||||
|
||||
except Exception as e:
|
||||
# If we fail early (e.g., auth), log to the first pending task
|
||||
for task_id in [
|
||||
|
||||
237
src/mail/actions/calendar_invite.py
Normal file
237
src/mail/actions/calendar_invite.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""Calendar invite actions for mail app.
|
||||
|
||||
Allows responding to calendar invites directly from email.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def detect_calendar_invite(message_content: str, headers: dict) -> Optional[str]:
|
||||
"""Detect if a message is a calendar invite and extract event ID if possible.
|
||||
|
||||
Calendar invites from Microsoft/Outlook typically have:
|
||||
- Content-Type: text/calendar or multipart with text/calendar part
|
||||
- Meeting ID patterns in the content
|
||||
- Teams/Outlook meeting links
|
||||
|
||||
Args:
|
||||
message_content: The message body content
|
||||
headers: Message headers
|
||||
|
||||
Returns:
|
||||
Event identifier hint if detected, None otherwise
|
||||
"""
|
||||
# Check for calendar-related content patterns
|
||||
calendar_patterns = [
|
||||
r"Microsoft Teams meeting",
|
||||
r"Join the meeting",
|
||||
r"Meeting ID:",
|
||||
r"teams\.microsoft\.com/l/meetup-join",
|
||||
r"Accept\s+Tentative\s+Decline",
|
||||
r"VEVENT",
|
||||
r"BEGIN:VCALENDAR",
|
||||
]
|
||||
|
||||
content_lower = message_content.lower() if message_content else ""
|
||||
|
||||
for pattern in calendar_patterns:
|
||||
if re.search(pattern, message_content or "", re.IGNORECASE):
|
||||
return "calendar_invite_detected"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def find_event_by_subject(
|
||||
subject: str, organizer_email: Optional[str] = None
|
||||
) -> Optional[dict]:
|
||||
"""Find a calendar event by subject and optionally organizer.
|
||||
|
||||
Args:
|
||||
subject: Event subject to search for
|
||||
organizer_email: Optional organizer email to filter by
|
||||
|
||||
Returns:
|
||||
Event dict if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
from src.services.microsoft_graph.auth import get_access_token
|
||||
from src.services.microsoft_graph.client import fetch_with_aiohttp
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
scopes = ["https://graph.microsoft.com/Calendars.Read"]
|
||||
_, headers = get_access_token(scopes)
|
||||
|
||||
# Search for events in the next 60 days with matching subject
|
||||
start_date = datetime.now()
|
||||
end_date = start_date + timedelta(days=60)
|
||||
|
||||
start_str = start_date.strftime("%Y-%m-%dT00:00:00Z")
|
||||
end_str = end_date.strftime("%Y-%m-%dT23:59:59Z")
|
||||
|
||||
# URL encode the subject for the filter
|
||||
subject_escaped = subject.replace("'", "''")
|
||||
|
||||
url = (
|
||||
f"https://graph.microsoft.com/v1.0/me/calendarView?"
|
||||
f"startDateTime={start_str}&endDateTime={end_str}&"
|
||||
f"$filter=contains(subject,'{subject_escaped}')&"
|
||||
f"$select=id,subject,organizer,start,end,responseStatus&"
|
||||
f"$top=10"
|
||||
)
|
||||
|
||||
response = await fetch_with_aiohttp(url, headers)
|
||||
if not response:
|
||||
return None
|
||||
events = response.get("value", [])
|
||||
|
||||
if events:
|
||||
# If organizer email provided, try to match
|
||||
if organizer_email:
|
||||
for event in events:
|
||||
org_email = (
|
||||
event.get("organizer", {})
|
||||
.get("emailAddress", {})
|
||||
.get("address", "")
|
||||
)
|
||||
if organizer_email.lower() in org_email.lower():
|
||||
return event
|
||||
|
||||
# Return first match
|
||||
return events[0]
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding event by subject: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def respond_to_calendar_invite(event_id: str, response: str) -> Tuple[bool, str]:
|
||||
"""Respond to a calendar invite.
|
||||
|
||||
Args:
|
||||
event_id: Microsoft Graph event ID
|
||||
response: Response type - 'accept', 'tentativelyAccept', or 'decline'
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
try:
|
||||
from src.services.microsoft_graph.auth import get_access_token
|
||||
from src.services.microsoft_graph.calendar import respond_to_invite
|
||||
|
||||
scopes = ["https://graph.microsoft.com/Calendars.ReadWrite"]
|
||||
_, headers = get_access_token(scopes)
|
||||
|
||||
success = await respond_to_invite(headers, event_id, response)
|
||||
|
||||
if success:
|
||||
response_text = {
|
||||
"accept": "accepted",
|
||||
"tentativelyAccept": "tentatively accepted",
|
||||
"decline": "declined",
|
||||
}.get(response, response)
|
||||
return True, f"Successfully {response_text} the meeting"
|
||||
else:
|
||||
return False, "Failed to respond to the meeting invite"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error responding to invite: {e}")
|
||||
return False, f"Error: {str(e)}"
|
||||
|
||||
|
||||
def action_accept_invite(app):
|
||||
"""Accept the current calendar invite."""
|
||||
_respond_to_current_invite(app, "accept")
|
||||
|
||||
|
||||
def action_decline_invite(app):
|
||||
"""Decline the current calendar invite."""
|
||||
_respond_to_current_invite(app, "decline")
|
||||
|
||||
|
||||
def action_tentative_invite(app):
|
||||
"""Tentatively accept the current calendar invite."""
|
||||
_respond_to_current_invite(app, "tentativelyAccept")
|
||||
|
||||
|
||||
def _respond_to_current_invite(app, response: str):
|
||||
"""Helper to respond to the current message's calendar invite."""
|
||||
current_message_id = app.current_message_id
|
||||
if not current_message_id:
|
||||
app.notify("No message selected", severity="warning")
|
||||
return
|
||||
|
||||
# Get message metadata
|
||||
metadata = app.message_store.get_metadata(current_message_id)
|
||||
if not metadata:
|
||||
app.notify("Could not load message metadata", severity="error")
|
||||
return
|
||||
|
||||
subject = metadata.get("subject", "")
|
||||
from_addr = metadata.get("from", {}).get("addr", "")
|
||||
|
||||
if not subject:
|
||||
app.notify(
|
||||
"No subject found - cannot match to calendar event", severity="warning"
|
||||
)
|
||||
return
|
||||
|
||||
# Run the async response in a worker
|
||||
app.run_worker(
|
||||
_async_respond_to_invite(app, subject, from_addr, response),
|
||||
exclusive=True,
|
||||
name="respond_invite",
|
||||
)
|
||||
|
||||
|
||||
async def _async_respond_to_invite(
|
||||
app, subject: str, organizer_email: str, response: str
|
||||
):
|
||||
"""Async worker to find and respond to calendar invite."""
|
||||
# First, find the event
|
||||
app.call_from_thread(app.notify, f"Searching for calendar event: {subject[:40]}...")
|
||||
|
||||
event = await find_event_by_subject(subject, organizer_email)
|
||||
|
||||
if not event:
|
||||
app.call_from_thread(
|
||||
app.notify,
|
||||
f"Could not find calendar event matching: {subject[:40]}",
|
||||
severity="warning",
|
||||
)
|
||||
return
|
||||
|
||||
event_id = event.get("id")
|
||||
if not event_id:
|
||||
app.call_from_thread(
|
||||
app.notify,
|
||||
"Could not get event ID from calendar",
|
||||
severity="error",
|
||||
)
|
||||
return
|
||||
|
||||
current_response = event.get("responseStatus", {}).get("response", "")
|
||||
|
||||
# Check if already responded
|
||||
if current_response == "accepted" and response == "accept":
|
||||
app.call_from_thread(
|
||||
app.notify, "Already accepted this invite", severity="information"
|
||||
)
|
||||
return
|
||||
elif current_response == "declined" and response == "decline":
|
||||
app.call_from_thread(
|
||||
app.notify, "Already declined this invite", severity="information"
|
||||
)
|
||||
return
|
||||
|
||||
# Respond to the invite
|
||||
success, message = await respond_to_calendar_invite(event_id, response)
|
||||
|
||||
severity = "information" if success else "error"
|
||||
app.call_from_thread(app.notify, message, severity=severity)
|
||||
259
src/mail/app.py
259
src/mail/app.py
@@ -8,6 +8,11 @@ from .screens.SearchPanel import SearchPanel
|
||||
from .actions.task import action_create_task
|
||||
from .actions.open import action_open
|
||||
from .actions.delete import delete_current
|
||||
from .actions.calendar_invite import (
|
||||
action_accept_invite,
|
||||
action_decline_invite,
|
||||
action_tentative_invite,
|
||||
)
|
||||
from src.services.taskwarrior import client as taskwarrior_client
|
||||
from src.services.himalaya import client as himalaya_client
|
||||
from src.utils.shared_config import get_theme_name
|
||||
@@ -77,6 +82,7 @@ class EmailViewerApp(App):
|
||||
search_query: Reactive[str] = reactive("") # Current search filter
|
||||
search_mode: Reactive[bool] = reactive(False) # True when showing search results
|
||||
_cached_envelopes: List[Dict[str, Any]] = [] # Cached envelopes before search
|
||||
_cached_metadata: Dict[int, Dict[str, Any]] = {} # Cached metadata before search
|
||||
|
||||
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
||||
yield from super().get_system_commands(screen)
|
||||
@@ -114,7 +120,8 @@ class EmailViewerApp(App):
|
||||
Binding("h", "toggle_header", "Toggle Envelope Header"),
|
||||
Binding("t", "create_task", "Create Task"),
|
||||
Binding("l", "open_links", "Show Links"),
|
||||
Binding("%", "reload", "Reload message list"),
|
||||
Binding("r", "reload", "Reload message list"),
|
||||
Binding("%", "reload", "Reload message list", show=False),
|
||||
Binding("1", "focus_1", "Focus Accounts Panel"),
|
||||
Binding("2", "focus_2", "Focus Folders Panel"),
|
||||
Binding("3", "focus_3", "Focus Envelopes Panel"),
|
||||
@@ -131,6 +138,10 @@ class EmailViewerApp(App):
|
||||
Binding("space", "toggle_selection", "Toggle selection"),
|
||||
Binding("escape", "clear_selection", "Clear selection"),
|
||||
Binding("/", "search", "Search"),
|
||||
Binding("u", "toggle_read", "Toggle read/unread"),
|
||||
Binding("A", "accept_invite", "Accept invite"),
|
||||
Binding("D", "decline_invite", "Decline invite"),
|
||||
Binding("T", "tentative_invite", "Tentative"),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -273,6 +284,12 @@ class EmailViewerApp(App):
|
||||
|
||||
async def _mark_message_as_read(self, message_id: int, index: int) -> None:
|
||||
"""Mark a message as read and update the UI."""
|
||||
# Skip if message_id is invalid or index is out of bounds
|
||||
if message_id <= 0:
|
||||
return
|
||||
if index < 0 or index >= len(self.message_store.envelopes):
|
||||
return
|
||||
|
||||
# Check if already read
|
||||
envelope_data = self.message_store.envelopes[index]
|
||||
if envelope_data and envelope_data.get("type") != "header":
|
||||
@@ -347,7 +364,13 @@ class EmailViewerApp(App):
|
||||
try:
|
||||
list_item = event.item
|
||||
label = list_item.query_one(Label)
|
||||
folder_name = str(label.renderable).strip()
|
||||
folder_text = str(label.renderable).strip()
|
||||
|
||||
# Extract folder name (remove count suffix like " [dim](10)[/dim]")
|
||||
# The format is "FolderName [dim](count)[/dim]" or just "FolderName"
|
||||
import re
|
||||
|
||||
folder_name = re.sub(r"\s*\[dim\]\(\d+\)\[/dim\]$", "", folder_text)
|
||||
|
||||
if folder_name and folder_name != self.folder:
|
||||
self.folder = folder_name
|
||||
@@ -483,14 +506,19 @@ class EmailViewerApp(App):
|
||||
async def fetch_folders(self) -> None:
|
||||
folders_list = self.query_one("#folders_list", ListView)
|
||||
folders_list.clear()
|
||||
|
||||
# Store folder names for count updates
|
||||
folder_names = ["INBOX"]
|
||||
|
||||
# Use the Himalaya client to fetch folders for current account
|
||||
account = self.current_account if self.current_account else None
|
||||
|
||||
folders_list.append(
|
||||
ListItem(Label("INBOX", classes="folder_name", markup=False))
|
||||
ListItem(Label("INBOX", classes="folder_name", markup=True))
|
||||
)
|
||||
try:
|
||||
folders_list.loading = True
|
||||
|
||||
# Use the Himalaya client to fetch folders for current account
|
||||
account = self.current_account if self.current_account else None
|
||||
folders, success = await himalaya_client.list_folders(account=account)
|
||||
|
||||
if success and folders:
|
||||
@@ -499,11 +527,12 @@ class EmailViewerApp(App):
|
||||
# Skip INBOX since we already added it
|
||||
if folder_name.upper() == "INBOX":
|
||||
continue
|
||||
folder_names.append(folder_name)
|
||||
item = ListItem(
|
||||
Label(
|
||||
folder_name,
|
||||
classes="folder_name",
|
||||
markup=False,
|
||||
markup=True,
|
||||
)
|
||||
)
|
||||
folders_list.append(item)
|
||||
@@ -514,6 +543,34 @@ class EmailViewerApp(App):
|
||||
finally:
|
||||
folders_list.loading = False
|
||||
|
||||
# Fetch counts in background and update labels
|
||||
self._update_folder_counts(folder_names, account)
|
||||
|
||||
@work(exclusive=False)
|
||||
async def _update_folder_counts(
|
||||
self, folder_names: List[str], account: str | None
|
||||
) -> None:
|
||||
"""Fetch and display message counts for folders."""
|
||||
import asyncio
|
||||
|
||||
folders_list = self.query_one("#folders_list", ListView)
|
||||
|
||||
async def get_count_for_folder(folder_name: str, index: int):
|
||||
count, success = await himalaya_client.get_folder_count(
|
||||
folder_name, account
|
||||
)
|
||||
if success and index < len(folders_list.children):
|
||||
try:
|
||||
list_item = folders_list.children[index]
|
||||
label = list_item.query_one(Label)
|
||||
label.update(f"{folder_name} [dim]({count})[/dim]")
|
||||
except Exception:
|
||||
pass # Widget may have been removed
|
||||
|
||||
# Fetch counts in parallel
|
||||
tasks = [get_count_for_folder(name, i) for i, name in enumerate(folder_names)]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
def _populate_list_view(self) -> None:
|
||||
"""Populate the ListView with new items using the new EnvelopeListItem widget."""
|
||||
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||
@@ -543,6 +600,9 @@ class EmailViewerApp(App):
|
||||
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||
for i, list_item in enumerate(envelopes_list.children):
|
||||
if isinstance(list_item, ListItem):
|
||||
# Bounds check - ListView and message_store may be out of sync during transitions
|
||||
if i >= len(self.message_store.envelopes):
|
||||
break
|
||||
item_data = self.message_store.envelopes[i]
|
||||
|
||||
if item_data and item_data.get("type") != "header":
|
||||
@@ -802,6 +862,18 @@ class EmailViewerApp(App):
|
||||
def action_create_task(self) -> None:
|
||||
action_create_task(self)
|
||||
|
||||
def action_accept_invite(self) -> None:
|
||||
"""Accept the calendar invite from the current email."""
|
||||
action_accept_invite(self)
|
||||
|
||||
def action_decline_invite(self) -> None:
|
||||
"""Decline the calendar invite from the current email."""
|
||||
action_decline_invite(self)
|
||||
|
||||
def action_tentative_invite(self) -> None:
|
||||
"""Tentatively accept the calendar invite from the current email."""
|
||||
action_tentative_invite(self)
|
||||
|
||||
def action_open_links(self) -> None:
|
||||
"""Open the link panel showing links from the current message."""
|
||||
content_container = self.query_one(ContentContainer)
|
||||
@@ -882,31 +954,97 @@ class EmailViewerApp(App):
|
||||
self._update_list_view_subtitle()
|
||||
|
||||
def action_clear_selection(self) -> None:
|
||||
"""Clear all selected messages and exit search mode."""
|
||||
"""Clear all selected messages or focus search input if in search mode."""
|
||||
# If in search mode, focus the search input instead of exiting
|
||||
if self.search_mode:
|
||||
search_panel = self.query_one("#search_panel", SearchPanel)
|
||||
search_panel.focus_input()
|
||||
return
|
||||
|
||||
if self.selected_messages:
|
||||
self.selected_messages.clear()
|
||||
self.refresh_list_view_items() # Refresh all items to uncheck checkboxes
|
||||
self._update_list_view_subtitle()
|
||||
|
||||
# Exit search mode if active
|
||||
if self.search_mode:
|
||||
search_panel = self.query_one("#search_panel", SearchPanel)
|
||||
search_panel.hide()
|
||||
self.search_mode = False
|
||||
self.search_query = ""
|
||||
async def action_toggle_read(self) -> None:
|
||||
"""Toggle read/unread status for the current or selected messages."""
|
||||
folder = self.folder if self.folder else None
|
||||
account = self.current_account if self.current_account else None
|
||||
|
||||
# Restore cached envelopes
|
||||
if self._cached_envelopes:
|
||||
self.message_store.envelopes = self._cached_envelopes
|
||||
self._cached_envelopes = []
|
||||
self._populate_list_view()
|
||||
if self.selected_messages:
|
||||
# Toggle multiple selected messages
|
||||
for message_id in self.selected_messages:
|
||||
await self._toggle_message_read_status(message_id, folder, account)
|
||||
self.show_status(
|
||||
f"Toggled read status for {len(self.selected_messages)} messages"
|
||||
)
|
||||
self.selected_messages.clear()
|
||||
else:
|
||||
# Toggle current message
|
||||
if self.current_message_id:
|
||||
await self._toggle_message_read_status(
|
||||
self.current_message_id, folder, account
|
||||
)
|
||||
|
||||
# Restore envelope list title
|
||||
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
||||
self.query_one(
|
||||
"#envelopes_list"
|
||||
).border_title = f"1️⃣ Emails {sort_indicator}"
|
||||
self._update_list_view_subtitle()
|
||||
# Refresh the list to show updated read status
|
||||
await self.fetch_envelopes().wait()
|
||||
|
||||
async def _toggle_message_read_status(
|
||||
self, message_id: int, folder: str | None, account: str | None
|
||||
) -> None:
|
||||
"""Toggle read status for a single message."""
|
||||
# Find the message in the store to check current status
|
||||
metadata = self.message_store.get_metadata(message_id)
|
||||
if not metadata:
|
||||
return
|
||||
|
||||
index = metadata.get("index", -1)
|
||||
if index < 0 or index >= len(self.message_store.envelopes):
|
||||
return
|
||||
|
||||
envelope_data = self.message_store.envelopes[index]
|
||||
if not envelope_data or envelope_data.get("type") == "header":
|
||||
return
|
||||
|
||||
flags = envelope_data.get("flags", [])
|
||||
is_read = "Seen" in flags
|
||||
|
||||
if is_read:
|
||||
# Mark as unread
|
||||
result, success = await himalaya_client.mark_as_unread(
|
||||
message_id, folder=folder, account=account
|
||||
)
|
||||
if success:
|
||||
if "Seen" in envelope_data.get("flags", []):
|
||||
envelope_data["flags"].remove("Seen")
|
||||
self.show_status(f"Marked message {message_id} as unread")
|
||||
self._update_envelope_read_state(index, is_read=False)
|
||||
else:
|
||||
# Mark as read
|
||||
result, success = await himalaya_client.mark_as_read(
|
||||
message_id, folder=folder, account=account
|
||||
)
|
||||
if success:
|
||||
if "flags" not in envelope_data:
|
||||
envelope_data["flags"] = []
|
||||
if "Seen" not in envelope_data["flags"]:
|
||||
envelope_data["flags"].append("Seen")
|
||||
self.show_status(f"Marked message {message_id} as read")
|
||||
self._update_envelope_read_state(index, is_read=True)
|
||||
|
||||
def _update_envelope_read_state(self, index: int, is_read: bool) -> None:
|
||||
"""Update the visual state of an envelope in the list."""
|
||||
try:
|
||||
list_view = self.query_one("#envelopes_list", ListView)
|
||||
list_item = list_view.children[index]
|
||||
envelope_widget = list_item.query_one(EnvelopeListItem)
|
||||
envelope_widget.is_read = is_read
|
||||
if is_read:
|
||||
envelope_widget.remove_class("unread")
|
||||
else:
|
||||
envelope_widget.add_class("unread")
|
||||
except Exception:
|
||||
pass # Widget may not exist
|
||||
|
||||
def action_oldest(self) -> None:
|
||||
self.fetch_envelopes() if self.reload_needed else None
|
||||
@@ -916,12 +1054,19 @@ class EmailViewerApp(App):
|
||||
self.fetch_envelopes() if self.reload_needed else None
|
||||
self.show_message(self.message_store.get_newest_id())
|
||||
|
||||
def action_reload(self) -> None:
|
||||
"""Reload the message list."""
|
||||
self.fetch_envelopes()
|
||||
self.show_status("Reloading messages...")
|
||||
|
||||
def action_search(self) -> None:
|
||||
"""Open the search panel."""
|
||||
search_panel = self.query_one("#search_panel", SearchPanel)
|
||||
if not search_panel.is_visible:
|
||||
# Cache current envelopes before searching
|
||||
self._cached_envelopes = list(self.message_store.envelopes)
|
||||
self._cached_metadata = dict(self.message_store.metadata_by_id)
|
||||
self.search_mode = True
|
||||
search_panel.show(self.search_query)
|
||||
|
||||
def on_search_panel_search_requested(
|
||||
@@ -943,10 +1088,13 @@ class EmailViewerApp(App):
|
||||
self.search_mode = False
|
||||
self.search_query = ""
|
||||
|
||||
# Restore cached envelopes
|
||||
# Restore cached envelopes and metadata
|
||||
if self._cached_envelopes:
|
||||
self.message_store.envelopes = self._cached_envelopes
|
||||
self._cached_envelopes = []
|
||||
if self._cached_metadata:
|
||||
self.message_store.metadata_by_id = self._cached_metadata
|
||||
self._cached_metadata = {}
|
||||
self._populate_list_view()
|
||||
|
||||
# Restore envelope list title
|
||||
@@ -1002,31 +1150,82 @@ class EmailViewerApp(App):
|
||||
|
||||
config = get_config()
|
||||
|
||||
# Add search results header
|
||||
# Build search header label
|
||||
if results:
|
||||
header_label = f"Search: '{query}' ({len(results)} result{'s' if len(results) != 1 else ''})"
|
||||
else:
|
||||
header_label = f"Search: '{query}' - No results found"
|
||||
envelopes_list.append(ListItem(GroupHeader(label=header_label)))
|
||||
|
||||
if not results:
|
||||
# Clear the message viewer when no results
|
||||
envelopes_list.append(ListItem(GroupHeader(label=header_label)))
|
||||
content_container = self.query_one(ContentContainer)
|
||||
content_container.clear_content()
|
||||
self.message_store.envelopes = []
|
||||
self.message_store.metadata_by_id = {}
|
||||
self.total_messages = 0
|
||||
self.current_message_id = 0
|
||||
return
|
||||
|
||||
# Create a temporary message store for search results
|
||||
# We need to include the search header in the envelopes so indices match
|
||||
search_store = MessageStore()
|
||||
search_store.load(results, self.sort_order_ascending)
|
||||
|
||||
# Store for navigation (replace main store temporarily)
|
||||
# Manually build envelopes list with search header first
|
||||
# so that ListView indices match message_store.envelopes indices
|
||||
grouped_envelopes = [{"type": "header", "label": header_label}]
|
||||
|
||||
# Sort results by date
|
||||
sorted_results = sorted(
|
||||
results,
|
||||
key=lambda x: x.get("date", ""),
|
||||
reverse=not self.sort_order_ascending,
|
||||
)
|
||||
|
||||
# Group by month and build metadata
|
||||
months: Dict[str, bool] = {}
|
||||
for envelope in sorted_results:
|
||||
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 (index matches grouped_envelopes)
|
||||
envelope_id = int(envelope["id"])
|
||||
search_store.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,
|
||||
}
|
||||
|
||||
search_store.envelopes = grouped_envelopes
|
||||
search_store.total_messages = len(search_store.metadata_by_id)
|
||||
|
||||
# Store for navigation (replace main store)
|
||||
self.message_store.envelopes = search_store.envelopes
|
||||
self.message_store.metadata_by_id = search_store.metadata_by_id
|
||||
self.total_messages = len(results)
|
||||
|
||||
for item in search_store.envelopes:
|
||||
# Build ListView to match envelopes list exactly
|
||||
for item in self.message_store.envelopes:
|
||||
if item and item.get("type") == "header":
|
||||
envelopes_list.append(ListItem(GroupHeader(label=item["label"])))
|
||||
elif item:
|
||||
|
||||
@@ -82,6 +82,10 @@ class ContentDisplayConfig(BaseModel):
|
||||
# View mode: "markdown" for pretty rendering, "html" for raw/plain display
|
||||
default_view_mode: Literal["markdown", "html"] = "markdown"
|
||||
|
||||
# URL compression: shorten long URLs for better readability
|
||||
compress_urls: bool = True
|
||||
max_url_length: int = 50 # Maximum length before URL is compressed
|
||||
|
||||
|
||||
class LinkPanelConfig(BaseModel):
|
||||
"""Configuration for the link panel."""
|
||||
|
||||
@@ -123,7 +123,7 @@ EnvelopeListItem .status-icon.unread {
|
||||
}
|
||||
|
||||
EnvelopeListItem .checkbox {
|
||||
width: 2;
|
||||
width: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -139,12 +139,12 @@ EnvelopeListItem .message-datetime {
|
||||
|
||||
EnvelopeListItem .email-subject {
|
||||
width: 1fr;
|
||||
padding: 0 4;
|
||||
padding: 0 3;
|
||||
}
|
||||
|
||||
EnvelopeListItem .email-preview {
|
||||
width: 1fr;
|
||||
padding: 0 4;
|
||||
padding: 0 3;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Input, Label, Button, ListView, ListItem
|
||||
from textual.containers import Vertical, Horizontal, Container
|
||||
from textual.binding import Binding
|
||||
from textual import on, work
|
||||
from src.services.task_client import create_task, get_backend_info
|
||||
from src.utils.ipc import notify_refresh
|
||||
|
||||
|
||||
class CreateTaskScreen(ModalScreen):
|
||||
@@ -208,6 +210,8 @@ class CreateTaskScreen(ModalScreen):
|
||||
|
||||
if success:
|
||||
self.app.show_status(f"Task created: {subject}", "success")
|
||||
# Notify the tasks app to refresh
|
||||
asyncio.create_task(notify_refresh("tasks", {"source": "mail"}))
|
||||
self.dismiss()
|
||||
else:
|
||||
self.app.show_status(f"Failed to create task: {result}", "error")
|
||||
|
||||
@@ -86,6 +86,9 @@ class LinkItem:
|
||||
- Keeping first and last path segments, eliding middle only if needed
|
||||
- Adapting to available width
|
||||
"""
|
||||
# Nerdfont chevron separator (nf-cod-chevron_right)
|
||||
sep = " \ueab6 "
|
||||
|
||||
# Special handling for common sites
|
||||
path = path.strip("/")
|
||||
|
||||
@@ -95,26 +98,26 @@ class LinkItem:
|
||||
if match:
|
||||
repo, type_, num = match.groups()
|
||||
icon = "#" if type_ == "issues" else "PR#"
|
||||
return f"{domain} > {repo} {icon}{num}"
|
||||
return f"{domain}{sep}{repo} {icon}{num}"
|
||||
|
||||
match = re.match(r"([^/]+/[^/]+)", path)
|
||||
if match:
|
||||
return f"{domain} > {match.group(1)}"
|
||||
return f"{domain}{sep}{match.group(1)}"
|
||||
|
||||
# Google Docs
|
||||
if "docs.google.com" in domain:
|
||||
if "/document/" in path:
|
||||
return f"{domain} > Document"
|
||||
return f"{domain}{sep}Document"
|
||||
if "/spreadsheets/" in path:
|
||||
return f"{domain} > Spreadsheet"
|
||||
return f"{domain}{sep}Spreadsheet"
|
||||
if "/presentation/" in path:
|
||||
return f"{domain} > Slides"
|
||||
return f"{domain}{sep}Slides"
|
||||
|
||||
# Jira/Atlassian
|
||||
if "atlassian.net" in domain or "jira" in domain.lower():
|
||||
match = re.search(r"([A-Z]+-\d+)", path)
|
||||
if match:
|
||||
return f"{domain} > {match.group(1)}"
|
||||
return f"{domain}{sep}{match.group(1)}"
|
||||
|
||||
# GitLab
|
||||
if "gitlab" in domain.lower():
|
||||
@@ -122,7 +125,7 @@ class LinkItem:
|
||||
if match:
|
||||
repo, type_, num = match.groups()
|
||||
icon = "#" if type_ == "issues" else "MR!"
|
||||
return f"{domain} > {repo} {icon}{num}"
|
||||
return f"{domain}{sep}{repo} {icon}{num}"
|
||||
|
||||
# Generic shortening - keep URL readable
|
||||
if len(url) <= max_len:
|
||||
@@ -136,31 +139,31 @@ class LinkItem:
|
||||
|
||||
# Try to fit the full path first
|
||||
full_path = "/".join(path_parts)
|
||||
result = f"{domain} > {full_path}"
|
||||
result = f"{domain}{sep}{full_path}"
|
||||
if len(result) <= max_len:
|
||||
return result
|
||||
|
||||
# Keep first segment + last two segments if possible
|
||||
if len(path_parts) >= 3:
|
||||
short_path = f"{path_parts[0]}/.../{path_parts[-2]}/{path_parts[-1]}"
|
||||
result = f"{domain} > {short_path}"
|
||||
result = f"{domain}{sep}{short_path}"
|
||||
if len(result) <= max_len:
|
||||
return result
|
||||
|
||||
# Keep first + last segment
|
||||
if len(path_parts) >= 2:
|
||||
short_path = f"{path_parts[0]}/.../{path_parts[-1]}"
|
||||
result = f"{domain} > {short_path}"
|
||||
result = f"{domain}{sep}{short_path}"
|
||||
if len(result) <= max_len:
|
||||
return result
|
||||
|
||||
# Just last segment
|
||||
result = f"{domain} > .../{path_parts[-1]}"
|
||||
result = f"{domain}{sep}.../{path_parts[-1]}"
|
||||
if len(result) <= max_len:
|
||||
return result
|
||||
|
||||
# Truncate with ellipsis as last resort
|
||||
result = f"{domain} > {path_parts[-1]}"
|
||||
result = f"{domain}{sep}{path_parts[-1]}"
|
||||
if len(result) > max_len:
|
||||
result = result[: max_len - 3] + "..."
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ Provides a search input docked to the top of the window with:
|
||||
- Live search with 1 second debounce
|
||||
- Cancel button to restore previous state
|
||||
- Help button showing Himalaya search syntax
|
||||
- Date picker for date/before/after keywords
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from textual.app import ComposeResult
|
||||
@@ -17,6 +19,34 @@ from textual.screen import ModalScreen
|
||||
from textual.timer import Timer
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Input, Label, Static
|
||||
from textual.suggester import SuggestFromList
|
||||
|
||||
from src.calendar.widgets.MonthCalendar import MonthCalendar
|
||||
|
||||
# Himalaya search keywords for autocomplete
|
||||
HIMALAYA_KEYWORDS = [
|
||||
"from ",
|
||||
"to ",
|
||||
"subject ",
|
||||
"body ",
|
||||
"date ",
|
||||
"before ",
|
||||
"after ",
|
||||
"flag ",
|
||||
"not ",
|
||||
"and ",
|
||||
"or ",
|
||||
"order by ",
|
||||
"order by date ",
|
||||
"order by date asc",
|
||||
"order by date desc",
|
||||
"order by from ",
|
||||
"order by to ",
|
||||
"order by subject ",
|
||||
"flag seen",
|
||||
"flag flagged",
|
||||
"not flag seen",
|
||||
]
|
||||
|
||||
HIMALAYA_SEARCH_HELP = """
|
||||
## Himalaya Search Query Syntax
|
||||
@@ -106,6 +136,94 @@ class SearchHelpModal(ModalScreen[None]):
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
class DatePickerModal(ModalScreen[Optional[date]]):
|
||||
"""Modal with a calendar for selecting a date."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
DatePickerModal {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
DatePickerModal > Vertical {
|
||||
width: 30;
|
||||
height: auto;
|
||||
border: solid $primary;
|
||||
background: $surface;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
DatePickerModal > Vertical > Label {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
DatePickerModal > Vertical > Horizontal {
|
||||
height: auto;
|
||||
align: center middle;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
DatePickerModal > Vertical > Horizontal > Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel"),
|
||||
Binding("left", "prev_month", "Previous month", show=False),
|
||||
Binding("right", "next_month", "Next month", show=False),
|
||||
Binding("enter", "select_date", "Select date", show=False),
|
||||
]
|
||||
|
||||
def __init__(self, keyword: str = "date") -> None:
|
||||
super().__init__()
|
||||
self.keyword = keyword
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical():
|
||||
yield Label(f"Select date for '{self.keyword}':", id="picker-title")
|
||||
yield MonthCalendar(id="date-picker-calendar")
|
||||
with Horizontal():
|
||||
yield Button("Today", variant="default", id="today-btn")
|
||||
yield Button("Select", variant="primary", id="select-btn")
|
||||
yield Button("Cancel", variant="warning", id="cancel-btn")
|
||||
|
||||
def on_month_calendar_date_selected(
|
||||
self, event: MonthCalendar.DateSelected
|
||||
) -> None:
|
||||
"""Handle date selection from calendar click."""
|
||||
self.dismiss(event.date)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "select-btn":
|
||||
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||
self.dismiss(calendar.selected_date)
|
||||
elif event.button.id == "today-btn":
|
||||
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||
today = date.today()
|
||||
calendar.selected_date = today
|
||||
calendar.display_month = today.replace(day=1)
|
||||
calendar.refresh()
|
||||
elif event.button.id == "cancel-btn":
|
||||
self.dismiss(None)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
def action_prev_month(self) -> None:
|
||||
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||
calendar.prev_month()
|
||||
|
||||
def action_next_month(self) -> None:
|
||||
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||
calendar.next_month()
|
||||
|
||||
def action_select_date(self) -> None:
|
||||
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
|
||||
self.dismiss(calendar.selected_date)
|
||||
|
||||
|
||||
class SearchPanel(Widget):
|
||||
"""Docked search panel with live search capability."""
|
||||
|
||||
@@ -125,7 +243,7 @@ class SearchPanel(Widget):
|
||||
}
|
||||
|
||||
SearchPanel > Horizontal {
|
||||
height: auto;
|
||||
height: 3;
|
||||
width: 100%;
|
||||
align: left middle;
|
||||
}
|
||||
@@ -190,6 +308,7 @@ class SearchPanel(Widget):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self._debounce_timer: Optional[Timer] = None
|
||||
self._last_query: str = ""
|
||||
self._pending_date_keyword: Optional[str] = None # Track keyword awaiting date
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal():
|
||||
@@ -197,11 +316,40 @@ class SearchPanel(Widget):
|
||||
yield Input(
|
||||
placeholder="from <name> or subject <text> or body <text>...",
|
||||
id="search-input",
|
||||
suggester=SuggestFromList(HIMALAYA_KEYWORDS, case_sensitive=False),
|
||||
)
|
||||
yield Label("", classes="search-status", id="search-status")
|
||||
yield Button("?", variant="default", id="help-btn")
|
||||
yield Button("Cancel", variant="warning", id="cancel-btn")
|
||||
|
||||
def _has_suggestion(self) -> bool:
|
||||
"""Check if the search input currently has an autocomplete suggestion."""
|
||||
try:
|
||||
input_widget = self.query_one("#search-input", Input)
|
||||
return bool(input_widget._suggestion and input_widget._cursor_at_end)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _accept_suggestion(self) -> bool:
|
||||
"""Accept the current autocomplete suggestion if present. Returns True if accepted."""
|
||||
try:
|
||||
input_widget = self.query_one("#search-input", Input)
|
||||
if input_widget._suggestion and input_widget._cursor_at_end:
|
||||
input_widget.value = input_widget._suggestion
|
||||
input_widget.cursor_position = len(input_widget.value)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
"""Handle key events to intercept Tab for autocomplete."""
|
||||
if event.key == "tab":
|
||||
# Try to accept suggestion; if successful, prevent default tab behavior
|
||||
if self._accept_suggestion():
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
def show(self, initial_query: str = "") -> None:
|
||||
"""Show the search panel and focus the input."""
|
||||
self.add_class("visible")
|
||||
@@ -216,6 +364,11 @@ class SearchPanel(Widget):
|
||||
self._cancel_debounce()
|
||||
self.result_count = -1
|
||||
|
||||
def focus_input(self) -> None:
|
||||
"""Focus the search input field."""
|
||||
input_widget = self.query_one("#search-input", Input)
|
||||
input_widget.focus()
|
||||
|
||||
@property
|
||||
def is_visible(self) -> bool:
|
||||
"""Check if the panel is visible."""
|
||||
@@ -234,12 +387,44 @@ class SearchPanel(Widget):
|
||||
|
||||
def _trigger_search(self) -> None:
|
||||
"""Trigger the actual search after debounce."""
|
||||
# Don't search if an autocomplete suggestion is visible
|
||||
if self._has_suggestion():
|
||||
return
|
||||
|
||||
query = self.query_one("#search-input", Input).value.strip()
|
||||
if query and query != self._last_query:
|
||||
self._last_query = query
|
||||
self.is_searching = True
|
||||
self.post_message(self.SearchRequested(query))
|
||||
|
||||
def _check_date_keyword(self, value: str) -> Optional[str]:
|
||||
"""Check if the input ends with a date keyword that needs a date picker.
|
||||
|
||||
Returns the keyword (date/before/after) if found, None otherwise.
|
||||
"""
|
||||
value_lower = value.lower()
|
||||
for keyword in ("date ", "before ", "after "):
|
||||
if value_lower.endswith(keyword):
|
||||
return keyword.strip()
|
||||
return None
|
||||
|
||||
def _show_date_picker(self, keyword: str) -> None:
|
||||
"""Show the date picker modal for the given keyword."""
|
||||
self._pending_date_keyword = keyword
|
||||
|
||||
def on_date_selected(selected_date: Optional[date]) -> None:
|
||||
if selected_date:
|
||||
# Insert the date into the search input
|
||||
input_widget = self.query_one("#search-input", Input)
|
||||
date_str = selected_date.strftime("%Y-%m-%d")
|
||||
input_widget.value = input_widget.value + date_str
|
||||
input_widget.cursor_position = len(input_widget.value)
|
||||
self._pending_date_keyword = None
|
||||
# Refocus the input
|
||||
self.query_one("#search-input", Input).focus()
|
||||
|
||||
self.app.push_screen(DatePickerModal(keyword), on_date_selected)
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
"""Handle input changes with debounce."""
|
||||
if event.input.id != "search-input":
|
||||
@@ -252,6 +437,12 @@ class SearchPanel(Widget):
|
||||
if not event.value.strip():
|
||||
return
|
||||
|
||||
# Check for date keywords and show picker
|
||||
date_keyword = self._check_date_keyword(event.value)
|
||||
if date_keyword:
|
||||
self._show_date_picker(date_keyword)
|
||||
return
|
||||
|
||||
# Set up new debounce timer (1 second)
|
||||
self._debounce_timer = self.set_timer(1.0, self._trigger_search)
|
||||
|
||||
|
||||
@@ -6,10 +6,15 @@ from textual.widgets import Static, Markdown, Label
|
||||
from textual.reactive import reactive
|
||||
from src.services.himalaya import client as himalaya_client
|
||||
from src.mail.config import get_config
|
||||
from src.mail.screens.LinkPanel import extract_links_from_content, LinkItem
|
||||
from src.mail.screens.LinkPanel import (
|
||||
extract_links_from_content,
|
||||
LinkItem,
|
||||
LinkItem as LinkItemClass,
|
||||
)
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Literal, List
|
||||
from typing import Literal, List, Dict
|
||||
from urllib.parse import urlparse
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
@@ -18,6 +23,88 @@ import sys
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def compress_urls_in_content(content: str, max_url_len: int = 50) -> str:
|
||||
"""Compress long URLs in markdown/text content for better readability.
|
||||
|
||||
Replaces long URLs with shortened versions using the same algorithm
|
||||
as LinkPanel._shorten_url. Preserves markdown link syntax.
|
||||
|
||||
Args:
|
||||
content: The markdown/text content to process
|
||||
max_url_len: Maximum length for displayed URLs (default 50)
|
||||
|
||||
Returns:
|
||||
Content with compressed URLs
|
||||
"""
|
||||
|
||||
# Pattern for markdown links: [text](url)
|
||||
def replace_md_link(match):
|
||||
anchor_text = match.group(1)
|
||||
url = match.group(2)
|
||||
|
||||
# Don't compress if URL is already short
|
||||
if len(url) <= max_url_len:
|
||||
return match.group(0)
|
||||
|
||||
# Use LinkItem's shortening algorithm
|
||||
short_url = LinkItemClass._shorten_url(
|
||||
url,
|
||||
urlparse(url).netloc.replace("www.", ""),
|
||||
urlparse(url).path,
|
||||
max_url_len,
|
||||
)
|
||||
|
||||
# Keep original anchor text, but if it's the same as URL, use short version
|
||||
if anchor_text == url or anchor_text.startswith("http"):
|
||||
return f"[\uf0c1 {short_url}]({url})"
|
||||
else:
|
||||
return match.group(0) # Keep original if anchor text is meaningful
|
||||
|
||||
# Pattern for bare URLs (not inside markdown links)
|
||||
def replace_bare_url(match):
|
||||
url = match.group(0)
|
||||
|
||||
# Don't compress if URL is already short
|
||||
if len(url) <= max_url_len:
|
||||
return url
|
||||
|
||||
parsed = urlparse(url)
|
||||
short_url = LinkItemClass._shorten_url(
|
||||
url, parsed.netloc.replace("www.", ""), parsed.path, max_url_len
|
||||
)
|
||||
|
||||
# Return as markdown link with icon
|
||||
return f"[\uf0c1 {short_url}]({url})"
|
||||
|
||||
# First, process markdown links
|
||||
md_link_pattern = r"\[([^\]]+)\]\((https?://[^)]+)\)"
|
||||
content = re.sub(md_link_pattern, replace_md_link, content)
|
||||
|
||||
# Then process bare URLs that aren't already in markdown links
|
||||
# This regex matches URLs not preceded by ]( which would indicate markdown link
|
||||
bare_url_pattern = r'(?<!\]\()https?://[^\s<>"\'\)]+[^\s<>"\'\.\,\)\]]'
|
||||
|
||||
# Use a more careful approach to avoid double-processing
|
||||
# Split content, process bare URLs, rejoin
|
||||
result = []
|
||||
last_end = 0
|
||||
|
||||
for match in re.finditer(bare_url_pattern, content):
|
||||
# Check if this URL is inside a markdown link (preceded by "](")
|
||||
prefix_start = max(0, match.start() - 2)
|
||||
prefix = content[prefix_start : match.start()]
|
||||
if prefix.endswith("]("):
|
||||
continue # Skip URLs that are already markdown link targets
|
||||
|
||||
result.append(content[last_end : match.start()])
|
||||
result.append(replace_bare_url(match))
|
||||
last_end = match.end()
|
||||
|
||||
result.append(content[last_end:])
|
||||
|
||||
return "".join(result)
|
||||
|
||||
|
||||
class EnvelopeHeader(Vertical):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -34,9 +121,12 @@ class EnvelopeHeader(Vertical):
|
||||
self.mount(self.to_label)
|
||||
self.mount(self.cc_label)
|
||||
self.mount(self.date_label)
|
||||
# Add bottom margin to subject for visual separation from metadata
|
||||
self.subject_label.styles.margin = (0, 0, 1, 0)
|
||||
|
||||
def update(self, subject, from_, to, date, cc=None):
|
||||
self.subject_label.update(f"[b]Subject:[/b] {subject}")
|
||||
# Subject is prominent - bold, bright white, no label needed
|
||||
self.subject_label.update(f"[b bright_white]{subject}[/b bright_white]")
|
||||
self.from_label.update(f"[b]From:[/b] {from_}")
|
||||
self.to_label.update(f"[b]To:[/b] {to}")
|
||||
|
||||
@@ -192,10 +282,18 @@ class ContentContainer(ScrollableContainer):
|
||||
# Store the raw content for link extraction
|
||||
self.current_content = content
|
||||
|
||||
# Get URL compression settings from config
|
||||
config = get_config()
|
||||
compress_urls = config.content_display.compress_urls
|
||||
max_url_len = config.content_display.max_url_length
|
||||
|
||||
try:
|
||||
if self.current_mode == "markdown":
|
||||
# For markdown mode, use the Markdown widget
|
||||
self.content.update(content)
|
||||
display_content = content
|
||||
if compress_urls:
|
||||
display_content = compress_urls_in_content(content, max_url_len)
|
||||
self.content.update(display_content)
|
||||
else:
|
||||
# For HTML mode, use the Static widget with markup
|
||||
# First, try to extract the body content if it's HTML
|
||||
|
||||
@@ -48,12 +48,7 @@ class EnvelopeListItem(Static):
|
||||
}
|
||||
|
||||
EnvelopeListItem .checkbox {
|
||||
width: 2;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
EnvelopeListItem .checkbox {
|
||||
width: 2;
|
||||
width: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,47 @@ async def list_folders(
|
||||
return [], False
|
||||
|
||||
|
||||
async def get_folder_count(
|
||||
folder: str,
|
||||
account: Optional[str] = None,
|
||||
) -> Tuple[int, bool]:
|
||||
"""
|
||||
Get the count of messages in a folder.
|
||||
|
||||
Args:
|
||||
folder: The folder to count messages in
|
||||
account: The account to use (defaults to default account)
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
- Message count
|
||||
- Success status (True if operation was successful)
|
||||
"""
|
||||
try:
|
||||
# Use a high limit to get all messages, then count them
|
||||
# This is the most reliable way with himalaya
|
||||
cmd = f"himalaya envelope list -o json -s 9999 -f '{folder}'"
|
||||
if account:
|
||||
cmd += f" -a '{account}'"
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
envelopes = json.loads(stdout.decode())
|
||||
return len(envelopes), True
|
||||
else:
|
||||
logging.error(f"Error getting folder count: {stderr.decode()}")
|
||||
return 0, False
|
||||
except Exception as e:
|
||||
logging.error(f"Exception during folder count: {e}")
|
||||
return 0, False
|
||||
|
||||
|
||||
async def delete_message(
|
||||
message_id: int,
|
||||
folder: Optional[str] = None,
|
||||
@@ -312,6 +353,49 @@ async def mark_as_read(
|
||||
return str(e), False
|
||||
|
||||
|
||||
async def mark_as_unread(
|
||||
message_id: int,
|
||||
folder: Optional[str] = None,
|
||||
account: Optional[str] = None,
|
||||
) -> Tuple[Optional[str], bool]:
|
||||
"""
|
||||
Mark a message as unread by removing the 'seen' flag.
|
||||
|
||||
Args:
|
||||
message_id: The ID of the message to mark as unread
|
||||
folder: The folder containing the message
|
||||
account: The account to use
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
- Result message or error
|
||||
- Success status (True if operation was successful)
|
||||
"""
|
||||
try:
|
||||
cmd = f"himalaya flag remove seen {message_id}"
|
||||
if folder:
|
||||
cmd += f" -f '{folder}'"
|
||||
if account:
|
||||
cmd += f" -a '{account}'"
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
return stdout.decode().strip() or "Marked as unread", True
|
||||
else:
|
||||
error_msg = stderr.decode().strip()
|
||||
logging.error(f"Error marking message as unread: {error_msg}")
|
||||
return error_msg or "Unknown error", False
|
||||
except Exception as e:
|
||||
logging.error(f"Exception during marking message as unread: {e}")
|
||||
return str(e), False
|
||||
|
||||
|
||||
async def search_envelopes(
|
||||
query: str,
|
||||
folder: Optional[str] = None,
|
||||
@@ -335,9 +419,33 @@ async def search_envelopes(
|
||||
- Success status (True if operation was successful)
|
||||
"""
|
||||
try:
|
||||
# Himalaya query keywords that indicate the user is writing a raw query
|
||||
query_keywords = (
|
||||
"from ",
|
||||
"to ",
|
||||
"subject ",
|
||||
"body ",
|
||||
"date ",
|
||||
"before ",
|
||||
"after ",
|
||||
"flag ",
|
||||
"not ",
|
||||
"order by ",
|
||||
)
|
||||
|
||||
# Check if user is using raw query syntax
|
||||
query_lower = query.lower()
|
||||
is_raw_query = any(query_lower.startswith(kw) for kw in query_keywords)
|
||||
|
||||
if is_raw_query:
|
||||
# Pass through as-is (user knows what they're doing)
|
||||
search_query = query
|
||||
else:
|
||||
# Build a compound query to search from, to, subject, and body
|
||||
# Himalaya query syntax: from <pattern> or to <pattern> or subject <pattern> or body <pattern>
|
||||
search_query = f"from {query} or to {query} or subject {query} or body {query}"
|
||||
search_query = (
|
||||
f"from {query} or to {query} or subject {query} or body {query}"
|
||||
)
|
||||
|
||||
# Build command with options before the query (query must be at the end, quoted)
|
||||
cmd = "himalaya envelope list -o json"
|
||||
|
||||
@@ -330,3 +330,44 @@ class KhalClient(CalendarBackend):
|
||||
# khal edit is interactive, so this is limited via CLI
|
||||
logger.warning("update_event not fully implemented for khal CLI")
|
||||
return None
|
||||
|
||||
def search_events(self, query: str) -> List[Event]:
|
||||
"""Search for events matching a query string.
|
||||
|
||||
Args:
|
||||
query: Search string to match against event titles and descriptions
|
||||
|
||||
Returns:
|
||||
List of matching events
|
||||
"""
|
||||
if not query:
|
||||
return []
|
||||
|
||||
# Use khal search with custom format
|
||||
format_str = "{title}|{start-time}|{end-time}|{start}|{end}|{location}|{uid}|{description}|{organizer}|{url}|{categories}|{status}|{repeat-symbol}"
|
||||
args = ["search", "-f", format_str, query]
|
||||
|
||||
result = self._run_khal(args)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"khal search failed: {result.stderr}")
|
||||
return []
|
||||
|
||||
events = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Skip day headers
|
||||
if ", " in line and "|" not in line:
|
||||
continue
|
||||
|
||||
event = self._parse_event_line(line)
|
||||
if event:
|
||||
events.append(event)
|
||||
|
||||
# Sort by start time
|
||||
events.sort(key=lambda e: e.start)
|
||||
|
||||
return events
|
||||
|
||||
@@ -25,6 +25,49 @@ def ensure_directory_exists(path):
|
||||
os.makedirs(path)
|
||||
|
||||
|
||||
def has_valid_cached_token(scopes=None):
|
||||
"""
|
||||
Check if we have a valid cached token (without triggering auth flow).
|
||||
|
||||
Args:
|
||||
scopes: List of scopes to check. If None, uses default scopes.
|
||||
|
||||
Returns:
|
||||
bool: True if a valid cached token exists, False otherwise.
|
||||
"""
|
||||
if scopes is None:
|
||||
scopes = ["https://graph.microsoft.com/Mail.Read"]
|
||||
|
||||
client_id = os.getenv("AZURE_CLIENT_ID")
|
||||
tenant_id = os.getenv("AZURE_TENANT_ID")
|
||||
|
||||
if not client_id or not tenant_id:
|
||||
return False
|
||||
|
||||
cache = msal.SerializableTokenCache()
|
||||
cache_file = "token_cache.bin"
|
||||
|
||||
if not os.path.exists(cache_file):
|
||||
return False
|
||||
|
||||
try:
|
||||
cache.deserialize(open(cache_file, "r").read())
|
||||
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||
app = msal.PublicClientApplication(
|
||||
client_id, authority=authority, token_cache=cache
|
||||
)
|
||||
accounts = app.get_accounts()
|
||||
|
||||
if not accounts:
|
||||
return False
|
||||
|
||||
# Try silent auth - this will return None if token is expired
|
||||
token_response = app.acquire_token_silent(scopes, account=accounts[0])
|
||||
return token_response is not None and "access_token" in token_response
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_access_token(scopes):
|
||||
"""
|
||||
Authenticate with Microsoft Graph API and obtain an access token.
|
||||
|
||||
@@ -111,7 +111,8 @@ async def fetch_mail_async(
|
||||
downloaded_count = 0
|
||||
|
||||
# Download messages in parallel batches for better performance
|
||||
BATCH_SIZE = 5
|
||||
# Using 10 concurrent downloads with connection pooling for better throughput
|
||||
BATCH_SIZE = 10
|
||||
|
||||
for i in range(0, len(messages_to_download), BATCH_SIZE):
|
||||
# Check if task was cancelled/disabled
|
||||
@@ -487,7 +488,8 @@ async def fetch_archive_mail_async(
|
||||
downloaded_count = 0
|
||||
|
||||
# Download messages in parallel batches for better performance
|
||||
BATCH_SIZE = 5
|
||||
# Using 10 concurrent downloads with connection pooling for better throughput
|
||||
BATCH_SIZE = 10
|
||||
|
||||
for i in range(0, len(messages_to_download), BATCH_SIZE):
|
||||
# Check if task was cancelled/disabled
|
||||
|
||||
137
src/tasks/app.py
137
src/tasks/app.py
@@ -12,12 +12,13 @@ from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import ScrollableContainer, Vertical, Horizontal
|
||||
from textual.logging import TextualHandler
|
||||
from textual.widgets import DataTable, Footer, Header, Static, Markdown
|
||||
from textual.widgets import DataTable, Footer, Header, Static, Markdown, Input
|
||||
|
||||
from .config import get_config, TasksAppConfig
|
||||
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
|
||||
from .widgets.FilterSidebar import FilterSidebar
|
||||
from src.utils.shared_config import get_theme_name
|
||||
from src.utils.ipc import IPCListener, IPCMessage
|
||||
|
||||
# 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__))))
|
||||
@@ -153,6 +154,30 @@ class TasksApp(App):
|
||||
#notes-pane.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#search-container {
|
||||
dock: top;
|
||||
height: 4;
|
||||
width: 100%;
|
||||
background: $surface;
|
||||
border-bottom: solid $primary;
|
||||
padding: 0 1;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
#search-container.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#search-container .search-label {
|
||||
width: auto;
|
||||
padding: 0 1;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
width: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
@@ -174,6 +199,8 @@ class TasksApp(App):
|
||||
Binding("r", "refresh", "Refresh", show=True),
|
||||
Binding("y", "sync", "Sync", show=True),
|
||||
Binding("?", "help", "Help", show=True),
|
||||
Binding("slash", "search", "Search", show=True),
|
||||
Binding("escape", "clear_search", "Clear Search", show=False),
|
||||
Binding("enter", "view_task", "View", show=False),
|
||||
]
|
||||
|
||||
@@ -208,6 +235,7 @@ class TasksApp(App):
|
||||
self.notes_visible = False
|
||||
self.detail_visible = False
|
||||
self.sidebar_visible = True # Start with sidebar visible
|
||||
self.current_search_query = "" # Current search filter
|
||||
self.config = get_config()
|
||||
|
||||
if backend:
|
||||
@@ -221,6 +249,12 @@ class TasksApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create the app layout."""
|
||||
yield Header()
|
||||
yield Horizontal(
|
||||
Static("\uf002 Search:", classes="search-label"), # nf-fa-search
|
||||
Input(placeholder="Filter tasks...", id="search-input", disabled=True),
|
||||
id="search-container",
|
||||
classes="hidden",
|
||||
)
|
||||
yield FilterSidebar(id="sidebar")
|
||||
yield Vertical(
|
||||
DataTable(id="task-table", cursor_type="row"),
|
||||
@@ -246,6 +280,11 @@ class TasksApp(App):
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the app on mount."""
|
||||
self.theme = get_theme_name()
|
||||
|
||||
# Start IPC listener for refresh notifications from sync daemon
|
||||
self._ipc_listener = IPCListener("tasks", self._on_ipc_message)
|
||||
self._ipc_listener.start()
|
||||
|
||||
table = self.query_one("#task-table", DataTable)
|
||||
|
||||
# Setup columns based on config with dynamic widths
|
||||
@@ -271,6 +310,9 @@ class TasksApp(App):
|
||||
# Load tasks (filtered by current filters)
|
||||
self.load_tasks()
|
||||
|
||||
# Focus the task table (not the hidden search input)
|
||||
table.focus()
|
||||
|
||||
def _setup_columns(self, table: DataTable, columns: list[str]) -> None:
|
||||
"""Setup table columns with dynamic widths based on available space."""
|
||||
# Minimum widths for each column type
|
||||
@@ -408,10 +450,11 @@ class TasksApp(App):
|
||||
self._update_sidebar()
|
||||
|
||||
def _filter_tasks(self, tasks: list[Task]) -> list[Task]:
|
||||
"""Filter tasks by current project and tag filters using OR logic.
|
||||
"""Filter tasks by current project, tag filters, and search query.
|
||||
|
||||
- If project filter is set, only show tasks from that project
|
||||
- If tag filters are set, show tasks that have ANY of the selected tags (OR)
|
||||
- If search query is set, filter by summary, notes, project, and tags
|
||||
"""
|
||||
filtered = tasks
|
||||
|
||||
@@ -427,6 +470,18 @@ class TasksApp(App):
|
||||
if any(tag in t.tags for tag in self.current_tag_filters)
|
||||
]
|
||||
|
||||
# Filter by search query (case-insensitive match on summary, notes, project, tags)
|
||||
if self.current_search_query:
|
||||
query = self.current_search_query.lower()
|
||||
filtered = [
|
||||
t
|
||||
for t in filtered
|
||||
if query in t.summary.lower()
|
||||
or (t.notes and query in t.notes.lower())
|
||||
or (t.project and query in t.project.lower())
|
||||
or any(query in tag.lower() for tag in t.tags)
|
||||
]
|
||||
|
||||
return filtered
|
||||
|
||||
def _update_sidebar(self) -> None:
|
||||
@@ -485,6 +540,8 @@ class TasksApp(App):
|
||||
status_bar.total_tasks = len(self.tasks)
|
||||
|
||||
filters = []
|
||||
if self.current_search_query:
|
||||
filters.append(f"\uf002 {self.current_search_query}") # nf-fa-search
|
||||
if self.current_project_filter:
|
||||
filters.append(f"project:{self.current_project_filter}")
|
||||
for tag in self.current_tag_filters:
|
||||
@@ -754,9 +811,10 @@ class TasksApp(App):
|
||||
self.notify(f"Sorted by {event.column} ({direction})")
|
||||
|
||||
def action_clear_filters(self) -> None:
|
||||
"""Clear all filters."""
|
||||
"""Clear all filters including search."""
|
||||
self.current_project_filter = None
|
||||
self.current_tag_filters = []
|
||||
self.current_search_query = ""
|
||||
|
||||
# Also clear sidebar selections
|
||||
try:
|
||||
@@ -765,6 +823,15 @@ class TasksApp(App):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clear and hide search input
|
||||
try:
|
||||
search_container = self.query_one("#search-container")
|
||||
search_input = self.query_one("#search-input", Input)
|
||||
search_input.value = ""
|
||||
search_container.add_class("hidden")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.load_tasks()
|
||||
self.notify("Filters cleared", severity="information")
|
||||
|
||||
@@ -800,6 +867,8 @@ Keybindings:
|
||||
x - Delete task
|
||||
w - Toggle filter sidebar
|
||||
c - Clear all filters
|
||||
/ - Search tasks
|
||||
Esc - Clear search
|
||||
r - Refresh
|
||||
y - Sync with remote
|
||||
Enter - View task details
|
||||
@@ -807,6 +876,56 @@ Keybindings:
|
||||
"""
|
||||
self.notify(help_text.strip(), timeout=10)
|
||||
|
||||
# Search actions
|
||||
def action_search(self) -> None:
|
||||
"""Show search input and focus it."""
|
||||
search_container = self.query_one("#search-container")
|
||||
search_container.remove_class("hidden")
|
||||
search_input = self.query_one("#search-input", Input)
|
||||
search_input.disabled = False
|
||||
search_input.focus()
|
||||
|
||||
def action_clear_search(self) -> None:
|
||||
"""Clear search and hide search input."""
|
||||
search_container = self.query_one("#search-container")
|
||||
search_input = self.query_one("#search-input", Input)
|
||||
|
||||
# Only act if search is visible or there's a query
|
||||
if not search_container.has_class("hidden") or self.current_search_query:
|
||||
search_input.value = ""
|
||||
search_input.disabled = True
|
||||
self.current_search_query = ""
|
||||
search_container.add_class("hidden")
|
||||
self.load_tasks()
|
||||
# Focus back to table
|
||||
table = self.query_one("#task-table", DataTable)
|
||||
table.focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle Enter in search input - apply search and focus table."""
|
||||
if event.input.id != "search-input":
|
||||
return
|
||||
|
||||
query = event.value.strip()
|
||||
self.current_search_query = query
|
||||
self.load_tasks()
|
||||
|
||||
# Focus back to table
|
||||
table = self.query_one("#task-table", DataTable)
|
||||
table.focus()
|
||||
|
||||
if query:
|
||||
self.notify(f"Searching: {query}")
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
"""Handle live search as user types."""
|
||||
if event.input.id != "search-input":
|
||||
return
|
||||
|
||||
# Live search - filter as user types
|
||||
self.current_search_query = event.value.strip()
|
||||
self.load_tasks()
|
||||
|
||||
# Notes actions
|
||||
def action_toggle_notes(self) -> None:
|
||||
"""Toggle the notes-only pane visibility."""
|
||||
@@ -897,6 +1016,18 @@ Keybindings:
|
||||
if task:
|
||||
self._update_detail_display(task)
|
||||
|
||||
def _on_ipc_message(self, message: IPCMessage) -> None:
|
||||
"""Handle IPC messages from sync daemon."""
|
||||
if message.event == "refresh":
|
||||
# Schedule a reload on the main thread
|
||||
self.call_from_thread(self.load_tasks)
|
||||
|
||||
async def action_quit(self) -> None:
|
||||
"""Quit the app and clean up IPC listener."""
|
||||
if hasattr(self, "_ipc_listener"):
|
||||
self._ipc_listener.stop()
|
||||
self.exit()
|
||||
|
||||
|
||||
def run_app(backend: Optional[TaskBackend] = None) -> None:
|
||||
"""Run the Tasks TUI application."""
|
||||
|
||||
@@ -27,6 +27,31 @@ from src.utils.mail_utils.helpers import (
|
||||
)
|
||||
|
||||
|
||||
# Module-level session for reuse
|
||||
_shared_session: aiohttp.ClientSession | None = None
|
||||
|
||||
|
||||
async def get_shared_session() -> aiohttp.ClientSession:
|
||||
"""Get or create a shared aiohttp session for connection reuse."""
|
||||
global _shared_session
|
||||
if _shared_session is None or _shared_session.closed:
|
||||
connector = aiohttp.TCPConnector(
|
||||
limit=20, # Max concurrent connections
|
||||
limit_per_host=10, # Max connections per host
|
||||
ttl_dns_cache=300, # Cache DNS for 5 minutes
|
||||
)
|
||||
_shared_session = aiohttp.ClientSession(connector=connector)
|
||||
return _shared_session
|
||||
|
||||
|
||||
async def close_shared_session():
|
||||
"""Close the shared session when done."""
|
||||
global _shared_session
|
||||
if _shared_session and not _shared_session.closed:
|
||||
await _shared_session.close()
|
||||
_shared_session = None
|
||||
|
||||
|
||||
async def save_mime_to_maildir_async(
|
||||
maildir_path,
|
||||
message,
|
||||
@@ -136,10 +161,13 @@ async def create_mime_message_async(
|
||||
|
||||
# First try the direct body content approach
|
||||
message_id = message.get("id", "")
|
||||
|
||||
# Get shared session for connection reuse
|
||||
session = await get_shared_session()
|
||||
|
||||
try:
|
||||
# First get the message with body content
|
||||
body_url = f"https://graph.microsoft.com/v1.0/me/messages/{message_id}?$select=body,bodyPreview"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(body_url, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
body_data = await response.json()
|
||||
@@ -176,23 +204,25 @@ async def create_mime_message_async(
|
||||
f"No direct body content for message {truncate_id(message_id)}, trying MIME content..."
|
||||
)
|
||||
await fetch_mime_content(
|
||||
mime_msg, message_id, headers, progress
|
||||
mime_msg, message_id, headers, progress, session
|
||||
)
|
||||
else:
|
||||
progress.console.print(
|
||||
f"Failed to get message body: {response.status}. Trying MIME content..."
|
||||
)
|
||||
await fetch_mime_content(mime_msg, message_id, headers, progress)
|
||||
await fetch_mime_content(
|
||||
mime_msg, message_id, headers, progress, session
|
||||
)
|
||||
except Exception as e:
|
||||
progress.console.print(
|
||||
f"Error getting message body: {e}. Trying MIME content..."
|
||||
)
|
||||
await fetch_mime_content(mime_msg, message_id, headers, progress)
|
||||
await fetch_mime_content(mime_msg, message_id, headers, progress, session)
|
||||
|
||||
# Handle attachments only if we want to download them
|
||||
if download_attachments:
|
||||
await add_attachments_async(
|
||||
mime_msg, message, headers, attachments_dir, progress
|
||||
mime_msg, message, headers, attachments_dir, progress, session
|
||||
)
|
||||
else:
|
||||
# Add a header to indicate attachment info was skipped
|
||||
@@ -201,7 +231,7 @@ async def create_mime_message_async(
|
||||
return mime_msg
|
||||
|
||||
|
||||
async def fetch_mime_content(mime_msg, message_id, headers, progress):
|
||||
async def fetch_mime_content(mime_msg, message_id, headers, progress, session=None):
|
||||
"""
|
||||
Fetch and add MIME content to a message when direct body access fails.
|
||||
|
||||
@@ -210,13 +240,17 @@ async def fetch_mime_content(mime_msg, message_id, headers, progress):
|
||||
message_id (str): Message ID.
|
||||
headers (dict): Headers including authentication.
|
||||
progress: Progress instance for updating progress bars.
|
||||
session (aiohttp.ClientSession, optional): Shared session to use.
|
||||
"""
|
||||
# Fallback to getting the MIME content
|
||||
message_content_url = (
|
||||
f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/$value"
|
||||
)
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# Use provided session or get shared session
|
||||
if session is None:
|
||||
session = await get_shared_session()
|
||||
|
||||
async with session.get(message_content_url, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
full_content = await response.text()
|
||||
@@ -275,7 +309,9 @@ async def fetch_mime_content(mime_msg, message_id, headers, progress):
|
||||
mime_msg.attach(MIMEText(f"Failed to retrieve message body: {str(e)}", "plain"))
|
||||
|
||||
|
||||
async def add_attachments_async(mime_msg, message, headers, attachments_dir, progress):
|
||||
async def add_attachments_async(
|
||||
mime_msg, message, headers, attachments_dir, progress, session=None
|
||||
):
|
||||
"""
|
||||
Add attachments to a MIME message.
|
||||
|
||||
@@ -285,6 +321,7 @@ async def add_attachments_async(mime_msg, message, headers, attachments_dir, pro
|
||||
headers (dict): Headers including authentication.
|
||||
attachments_dir (str): Path to save attachments.
|
||||
progress: Progress instance for updating progress bars.
|
||||
session (aiohttp.ClientSession, optional): Shared session to use.
|
||||
|
||||
Returns:
|
||||
None
|
||||
@@ -296,7 +333,10 @@ async def add_attachments_async(mime_msg, message, headers, attachments_dir, pro
|
||||
f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/attachments"
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# Use provided session or get shared session
|
||||
if session is None:
|
||||
session = await get_shared_session()
|
||||
|
||||
async with session.get(attachments_url, headers=headers) as response:
|
||||
if response.status != 200:
|
||||
return
|
||||
@@ -316,9 +356,7 @@ async def add_attachments_async(mime_msg, message, headers, attachments_dir, pro
|
||||
|
||||
for idx, attachment in enumerate(attachments):
|
||||
attachment_name = safe_filename(attachment.get("name", "attachment"))
|
||||
attachment_type = attachment.get(
|
||||
"contentType", "application/octet-stream"
|
||||
)
|
||||
attachment_type = attachment.get("contentType", "application/octet-stream")
|
||||
|
||||
# Add attachment info to headers for reference
|
||||
mime_msg[f"X-Attachment-{idx + 1}-Name"] = attachment_name
|
||||
@@ -331,9 +369,7 @@ async def add_attachments_async(mime_msg, message, headers, attachments_dir, pro
|
||||
attachment_content = base64.b64decode(attachment["contentBytes"])
|
||||
|
||||
# Save attachment to disk
|
||||
attachment_path = os.path.join(
|
||||
message_attachments_dir, attachment_name
|
||||
)
|
||||
attachment_path = os.path.join(message_attachments_dir, attachment_name)
|
||||
with open(attachment_path, "wb") as f:
|
||||
f.write(attachment_content)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user