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
|
### Phase 1: Critical/High Priority
|
||||||
1. ~~Tasks App: Fix table display~~ (DONE)
|
1. ~~Tasks App: Fix table display~~ (DONE)
|
||||||
2. Sync: Parallelize message downloads
|
2. ~~Sync: Parallelize message downloads~~ (DONE - connection pooling + batch size increase)
|
||||||
3. Mail: Replace hardcoded RGB colors
|
3. ~~Mail: Replace hardcoded RGB colors~~ (DONE - already using theme variables)
|
||||||
4. Mail: Remove envelope icon/checkbox gap
|
4. ~~Mail: Remove envelope icon/checkbox gap~~ (DONE)
|
||||||
5. Calendar: Current time hour line styling
|
5. ~~Calendar: Current time hour line styling~~ (DONE - added surface background)
|
||||||
6. IPC: Implement cross-app refresh notifications
|
6. ~~IPC: Implement cross-app refresh notifications~~ (DONE)
|
||||||
|
|
||||||
### Phase 2: Medium Priority
|
### Phase 2: Medium Priority
|
||||||
1. Sync: Default to TUI mode
|
1. ~~Sync: Default to TUI mode~~ (DONE - already implemented)
|
||||||
2. Calendar: Cursor hour header highlighting
|
2. ~~Calendar: Cursor hour header highlighting~~ (DONE)
|
||||||
3. Calendar: Responsive detail panel
|
3. Calendar: Responsive detail panel
|
||||||
4. Calendar: Sidebar mini-calendar
|
4. Calendar: Sidebar mini-calendar
|
||||||
5. Calendar: Calendar invites sidebar
|
5. Calendar: Calendar invites sidebar
|
||||||
6. Mail: Add refresh keybinding
|
6. ~~Mail: Add refresh keybinding~~ (DONE - `r` key)
|
||||||
7. Mail: Add mark read/unread action
|
7. ~~Mail: Add mark read/unread action~~ (DONE - `u` key)
|
||||||
8. Mail: Folder message counts
|
8. ~~Mail: Folder message counts~~ (DONE)
|
||||||
9. Mail: URL compression in markdown view
|
8. ~~Mail: URL compression in markdown view~~ (DONE)
|
||||||
10. Mail: Enhance subject styling
|
9. ~~Mail: Enhance subject styling~~ (DONE)
|
||||||
11. Mail: Search feature
|
10. Mail: Search feature
|
||||||
12. Tasks: Search feature
|
11. ~~Tasks: Search feature~~ (DONE - `/` key with live filtering)
|
||||||
13. Calendar: Search feature
|
12. ~~Calendar: Search feature~~ (DONE - `/` key using khal search)
|
||||||
|
|
||||||
### Phase 3: Low Priority
|
### Phase 3: Low Priority
|
||||||
1. Sync: UI consistency (j/k navigation, borders)
|
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.binding import Binding
|
||||||
from textual.containers import Container, Horizontal, Vertical
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
from textual.logging import TextualHandler
|
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 textual.reactive import reactive
|
||||||
|
|
||||||
from src.calendar.backend import CalendarBackend, Event
|
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.InvitesPanel import InvitesPanel, CalendarInvite
|
||||||
from src.calendar.widgets.AddEventForm import EventFormData
|
from src.calendar.widgets.AddEventForm import EventFormData
|
||||||
from src.utils.shared_config import get_theme_name
|
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
|
# 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__))))
|
||||||
@@ -115,6 +116,42 @@ class CalendarApp(App):
|
|||||||
#event-detail.hidden {
|
#event-detail.hidden {
|
||||||
display: none;
|
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 = [
|
BINDINGS = [
|
||||||
@@ -132,6 +169,8 @@ class CalendarApp(App):
|
|||||||
Binding("r", "refresh", "Refresh", show=True),
|
Binding("r", "refresh", "Refresh", show=True),
|
||||||
Binding("enter", "view_event", "View", show=True),
|
Binding("enter", "view_event", "View", show=True),
|
||||||
Binding("a", "add_event", "Add", 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),
|
Binding("?", "help", "Help", show=True),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -142,10 +181,12 @@ class CalendarApp(App):
|
|||||||
# Instance attributes
|
# Instance attributes
|
||||||
backend: Optional[CalendarBackend]
|
backend: Optional[CalendarBackend]
|
||||||
_invites: list[CalendarInvite]
|
_invites: list[CalendarInvite]
|
||||||
|
_search_results: list[Event]
|
||||||
|
|
||||||
def __init__(self, backend: Optional[CalendarBackend] = None):
|
def __init__(self, backend: Optional[CalendarBackend] = None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._invites = []
|
self._invites = []
|
||||||
|
self._search_results = []
|
||||||
|
|
||||||
if backend:
|
if backend:
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
@@ -158,11 +199,18 @@ class CalendarApp(App):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create the app layout."""
|
"""Create the app layout."""
|
||||||
yield Header()
|
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 Horizontal(id="main-content"):
|
||||||
with Vertical(id="sidebar"):
|
with Vertical(id="sidebar"):
|
||||||
yield MonthCalendar(id="sidebar-calendar")
|
yield MonthCalendar(id="sidebar-calendar")
|
||||||
yield InvitesPanel(id="sidebar-invites")
|
yield InvitesPanel(id="sidebar-invites")
|
||||||
yield WeekGrid(id="week-grid")
|
yield WeekGrid(id="week-grid")
|
||||||
|
yield Static(id="search-results", classes="hidden")
|
||||||
yield Static(id="event-detail", classes="hidden")
|
yield Static(id="event-detail", classes="hidden")
|
||||||
yield CalendarStatusBar(id="status-bar")
|
yield CalendarStatusBar(id="status-bar")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
@@ -171,6 +219,10 @@ class CalendarApp(App):
|
|||||||
"""Initialize the app on mount."""
|
"""Initialize the app on mount."""
|
||||||
self.theme = get_theme_name()
|
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
|
# Load events for current week
|
||||||
self.load_events()
|
self.load_events()
|
||||||
|
|
||||||
@@ -184,6 +236,15 @@ class CalendarApp(App):
|
|||||||
self._update_status()
|
self._update_status()
|
||||||
self._update_title()
|
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:
|
async def _load_invites_async(self) -> None:
|
||||||
"""Load pending calendar invites from Microsoft Graph."""
|
"""Load pending calendar invites from Microsoft Graph."""
|
||||||
try:
|
try:
|
||||||
@@ -521,6 +582,8 @@ Keybindings:
|
|||||||
w - Toggle weekends (5/7 days)
|
w - Toggle weekends (5/7 days)
|
||||||
s - Toggle sidebar
|
s - Toggle sidebar
|
||||||
i - Focus invites panel
|
i - Focus invites panel
|
||||||
|
/ - Search events
|
||||||
|
Esc - Clear search
|
||||||
Enter - View event details
|
Enter - View event details
|
||||||
a - Add new event
|
a - Add new event
|
||||||
r - Refresh
|
r - Refresh
|
||||||
@@ -528,6 +591,90 @@ Keybindings:
|
|||||||
"""
|
"""
|
||||||
self.notify(help_text.strip(), timeout=10)
|
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:
|
def action_focus_invites(self) -> None:
|
||||||
"""Focus on the invites panel and show invite count."""
|
"""Focus on the invites panel and show invite count."""
|
||||||
if not self.show_sidebar:
|
if not self.show_sidebar:
|
||||||
|
|||||||
@@ -216,3 +216,17 @@ class CalendarBackend(ABC):
|
|||||||
by_date[d].sort(key=lambda e: e.start)
|
by_date[d].sort(key=lambda e: e.start)
|
||||||
|
|
||||||
return by_date
|
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)
|
current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row)
|
||||||
is_current_time_row = row_index == current_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)
|
# Time label (only show on the hour)
|
||||||
if row_index % rows_per_hour == 0:
|
if row_index % rows_per_hour == 0:
|
||||||
hour = row_index // rows_per_hour
|
hour = row_index // rows_per_hour
|
||||||
@@ -371,10 +374,16 @@ class WeekGridBody(ScrollView):
|
|||||||
else:
|
else:
|
||||||
time_str = " " # Blank for half-hour
|
time_str = " " # Blank for half-hour
|
||||||
|
|
||||||
# Style time label - highlight current time, dim outside work hours
|
# Style time label - highlight current time or cursor, dim outside work hours
|
||||||
if is_current_time_row:
|
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")
|
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 (
|
elif (
|
||||||
row_index < self._work_day_start * rows_per_hour
|
row_index < self._work_day_start * rows_per_hour
|
||||||
or row_index >= self._work_day_end * rows_per_hour
|
or row_index >= self._work_day_end * rows_per_hour
|
||||||
|
|||||||
@@ -715,6 +715,24 @@ def sync(
|
|||||||
else:
|
else:
|
||||||
# Default: Launch interactive TUI dashboard
|
# Default: Launch interactive TUI dashboard
|
||||||
from .sync_dashboard import run_dashboard_sync
|
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 = {
|
sync_config = {
|
||||||
"org": org,
|
"org": org,
|
||||||
@@ -936,6 +954,27 @@ def status():
|
|||||||
def interactive(org, vdir, notify, dry_run, demo):
|
def interactive(org, vdir, notify, dry_run, demo):
|
||||||
"""Launch interactive TUI dashboard for sync operations."""
|
"""Launch interactive TUI dashboard for sync operations."""
|
||||||
from .sync_dashboard import run_dashboard_sync
|
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 = {
|
sync_config = {
|
||||||
"org": org,
|
"org": org,
|
||||||
|
|||||||
@@ -1038,6 +1038,11 @@ async def run_dashboard_sync(
|
|||||||
# Schedule next sync
|
# Schedule next sync
|
||||||
dashboard.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:
|
except Exception as e:
|
||||||
tracker.error_task("archive", str(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.calendar_utils import save_events_to_vdir, save_events_to_file
|
||||||
from src.utils.notifications import notify_new_emails
|
from src.utils.notifications import notify_new_emails
|
||||||
|
from src.utils.ipc import notify_all
|
||||||
|
|
||||||
config = dashboard._sync_config
|
config = dashboard._sync_config
|
||||||
|
|
||||||
@@ -1372,6 +1378,9 @@ async def run_dashboard_sync(
|
|||||||
# Schedule next sync
|
# Schedule next sync
|
||||||
dashboard.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:
|
except Exception as e:
|
||||||
# If we fail early (e.g., auth), log to the first pending task
|
# If we fail early (e.g., auth), log to the first pending task
|
||||||
for task_id in [
|
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)
|
||||||
261
src/mail/app.py
261
src/mail/app.py
@@ -8,6 +8,11 @@ from .screens.SearchPanel import SearchPanel
|
|||||||
from .actions.task import action_create_task
|
from .actions.task import action_create_task
|
||||||
from .actions.open import action_open
|
from .actions.open import action_open
|
||||||
from .actions.delete import delete_current
|
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.taskwarrior import client as taskwarrior_client
|
||||||
from src.services.himalaya import client as himalaya_client
|
from src.services.himalaya import client as himalaya_client
|
||||||
from src.utils.shared_config import get_theme_name
|
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_query: Reactive[str] = reactive("") # Current search filter
|
||||||
search_mode: Reactive[bool] = reactive(False) # True when showing search results
|
search_mode: Reactive[bool] = reactive(False) # True when showing search results
|
||||||
_cached_envelopes: List[Dict[str, Any]] = [] # Cached envelopes before search
|
_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]:
|
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
||||||
yield from super().get_system_commands(screen)
|
yield from super().get_system_commands(screen)
|
||||||
@@ -114,7 +120,8 @@ class EmailViewerApp(App):
|
|||||||
Binding("h", "toggle_header", "Toggle Envelope Header"),
|
Binding("h", "toggle_header", "Toggle Envelope Header"),
|
||||||
Binding("t", "create_task", "Create Task"),
|
Binding("t", "create_task", "Create Task"),
|
||||||
Binding("l", "open_links", "Show Links"),
|
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("1", "focus_1", "Focus Accounts Panel"),
|
||||||
Binding("2", "focus_2", "Focus Folders Panel"),
|
Binding("2", "focus_2", "Focus Folders Panel"),
|
||||||
Binding("3", "focus_3", "Focus Envelopes Panel"),
|
Binding("3", "focus_3", "Focus Envelopes Panel"),
|
||||||
@@ -131,6 +138,10 @@ class EmailViewerApp(App):
|
|||||||
Binding("space", "toggle_selection", "Toggle selection"),
|
Binding("space", "toggle_selection", "Toggle selection"),
|
||||||
Binding("escape", "clear_selection", "Clear selection"),
|
Binding("escape", "clear_selection", "Clear selection"),
|
||||||
Binding("/", "search", "Search"),
|
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:
|
async def _mark_message_as_read(self, message_id: int, index: int) -> None:
|
||||||
"""Mark a message as read and update the UI."""
|
"""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
|
# Check if already read
|
||||||
envelope_data = self.message_store.envelopes[index]
|
envelope_data = self.message_store.envelopes[index]
|
||||||
if envelope_data and envelope_data.get("type") != "header":
|
if envelope_data and envelope_data.get("type") != "header":
|
||||||
@@ -347,7 +364,13 @@ class EmailViewerApp(App):
|
|||||||
try:
|
try:
|
||||||
list_item = event.item
|
list_item = event.item
|
||||||
label = list_item.query_one(Label)
|
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:
|
if folder_name and folder_name != self.folder:
|
||||||
self.folder = folder_name
|
self.folder = folder_name
|
||||||
@@ -483,14 +506,19 @@ class EmailViewerApp(App):
|
|||||||
async def fetch_folders(self) -> None:
|
async def fetch_folders(self) -> None:
|
||||||
folders_list = self.query_one("#folders_list", ListView)
|
folders_list = self.query_one("#folders_list", ListView)
|
||||||
folders_list.clear()
|
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(
|
folders_list.append(
|
||||||
ListItem(Label("INBOX", classes="folder_name", markup=False))
|
ListItem(Label("INBOX", classes="folder_name", markup=True))
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
folders_list.loading = True
|
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)
|
folders, success = await himalaya_client.list_folders(account=account)
|
||||||
|
|
||||||
if success and folders:
|
if success and folders:
|
||||||
@@ -499,11 +527,12 @@ class EmailViewerApp(App):
|
|||||||
# Skip INBOX since we already added it
|
# Skip INBOX since we already added it
|
||||||
if folder_name.upper() == "INBOX":
|
if folder_name.upper() == "INBOX":
|
||||||
continue
|
continue
|
||||||
|
folder_names.append(folder_name)
|
||||||
item = ListItem(
|
item = ListItem(
|
||||||
Label(
|
Label(
|
||||||
folder_name,
|
folder_name,
|
||||||
classes="folder_name",
|
classes="folder_name",
|
||||||
markup=False,
|
markup=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
folders_list.append(item)
|
folders_list.append(item)
|
||||||
@@ -514,6 +543,34 @@ class EmailViewerApp(App):
|
|||||||
finally:
|
finally:
|
||||||
folders_list.loading = False
|
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:
|
def _populate_list_view(self) -> None:
|
||||||
"""Populate the ListView with new items using the new EnvelopeListItem widget."""
|
"""Populate the ListView with new items using the new EnvelopeListItem widget."""
|
||||||
envelopes_list = self.query_one("#envelopes_list", ListView)
|
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||||
@@ -543,6 +600,9 @@ class EmailViewerApp(App):
|
|||||||
envelopes_list = self.query_one("#envelopes_list", ListView)
|
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||||
for i, list_item in enumerate(envelopes_list.children):
|
for i, list_item in enumerate(envelopes_list.children):
|
||||||
if isinstance(list_item, ListItem):
|
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]
|
item_data = self.message_store.envelopes[i]
|
||||||
|
|
||||||
if item_data and item_data.get("type") != "header":
|
if item_data and item_data.get("type") != "header":
|
||||||
@@ -802,6 +862,18 @@ class EmailViewerApp(App):
|
|||||||
def action_create_task(self) -> None:
|
def action_create_task(self) -> None:
|
||||||
action_create_task(self)
|
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:
|
def action_open_links(self) -> None:
|
||||||
"""Open the link panel showing links from the current message."""
|
"""Open the link panel showing links from the current message."""
|
||||||
content_container = self.query_one(ContentContainer)
|
content_container = self.query_one(ContentContainer)
|
||||||
@@ -882,31 +954,97 @@ class EmailViewerApp(App):
|
|||||||
self._update_list_view_subtitle()
|
self._update_list_view_subtitle()
|
||||||
|
|
||||||
def action_clear_selection(self) -> None:
|
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:
|
if self.selected_messages:
|
||||||
self.selected_messages.clear()
|
self.selected_messages.clear()
|
||||||
self.refresh_list_view_items() # Refresh all items to uncheck checkboxes
|
self.refresh_list_view_items() # Refresh all items to uncheck checkboxes
|
||||||
self._update_list_view_subtitle()
|
self._update_list_view_subtitle()
|
||||||
|
|
||||||
# Exit search mode if active
|
async def action_toggle_read(self) -> None:
|
||||||
if self.search_mode:
|
"""Toggle read/unread status for the current or selected messages."""
|
||||||
search_panel = self.query_one("#search_panel", SearchPanel)
|
folder = self.folder if self.folder else None
|
||||||
search_panel.hide()
|
account = self.current_account if self.current_account else None
|
||||||
self.search_mode = False
|
|
||||||
self.search_query = ""
|
|
||||||
|
|
||||||
# Restore cached envelopes
|
if self.selected_messages:
|
||||||
if self._cached_envelopes:
|
# Toggle multiple selected messages
|
||||||
self.message_store.envelopes = self._cached_envelopes
|
for message_id in self.selected_messages:
|
||||||
self._cached_envelopes = []
|
await self._toggle_message_read_status(message_id, folder, account)
|
||||||
self._populate_list_view()
|
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
|
# Refresh the list to show updated read status
|
||||||
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
await self.fetch_envelopes().wait()
|
||||||
self.query_one(
|
|
||||||
"#envelopes_list"
|
async def _toggle_message_read_status(
|
||||||
).border_title = f"1️⃣ Emails {sort_indicator}"
|
self, message_id: int, folder: str | None, account: str | None
|
||||||
self._update_list_view_subtitle()
|
) -> 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:
|
def action_oldest(self) -> None:
|
||||||
self.fetch_envelopes() if self.reload_needed else 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.fetch_envelopes() if self.reload_needed else None
|
||||||
self.show_message(self.message_store.get_newest_id())
|
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:
|
def action_search(self) -> None:
|
||||||
"""Open the search panel."""
|
"""Open the search panel."""
|
||||||
search_panel = self.query_one("#search_panel", SearchPanel)
|
search_panel = self.query_one("#search_panel", SearchPanel)
|
||||||
if not search_panel.is_visible:
|
if not search_panel.is_visible:
|
||||||
# Cache current envelopes before searching
|
# Cache current envelopes before searching
|
||||||
self._cached_envelopes = list(self.message_store.envelopes)
|
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)
|
search_panel.show(self.search_query)
|
||||||
|
|
||||||
def on_search_panel_search_requested(
|
def on_search_panel_search_requested(
|
||||||
@@ -943,11 +1088,14 @@ class EmailViewerApp(App):
|
|||||||
self.search_mode = False
|
self.search_mode = False
|
||||||
self.search_query = ""
|
self.search_query = ""
|
||||||
|
|
||||||
# Restore cached envelopes
|
# Restore cached envelopes and metadata
|
||||||
if self._cached_envelopes:
|
if self._cached_envelopes:
|
||||||
self.message_store.envelopes = self._cached_envelopes
|
self.message_store.envelopes = self._cached_envelopes
|
||||||
self._cached_envelopes = []
|
self._cached_envelopes = []
|
||||||
self._populate_list_view()
|
if self._cached_metadata:
|
||||||
|
self.message_store.metadata_by_id = self._cached_metadata
|
||||||
|
self._cached_metadata = {}
|
||||||
|
self._populate_list_view()
|
||||||
|
|
||||||
# Restore envelope list title
|
# Restore envelope list title
|
||||||
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
||||||
@@ -1002,31 +1150,82 @@ class EmailViewerApp(App):
|
|||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
|
||||||
# Add search results header
|
# Build search header label
|
||||||
if results:
|
if results:
|
||||||
header_label = f"Search: '{query}' ({len(results)} result{'s' if len(results) != 1 else ''})"
|
header_label = f"Search: '{query}' ({len(results)} result{'s' if len(results) != 1 else ''})"
|
||||||
else:
|
else:
|
||||||
header_label = f"Search: '{query}' - No results found"
|
header_label = f"Search: '{query}' - No results found"
|
||||||
envelopes_list.append(ListItem(GroupHeader(label=header_label)))
|
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
# Clear the message viewer when no results
|
# Clear the message viewer when no results
|
||||||
|
envelopes_list.append(ListItem(GroupHeader(label=header_label)))
|
||||||
content_container = self.query_one(ContentContainer)
|
content_container = self.query_one(ContentContainer)
|
||||||
content_container.clear_content()
|
content_container.clear_content()
|
||||||
self.message_store.envelopes = []
|
self.message_store.envelopes = []
|
||||||
|
self.message_store.metadata_by_id = {}
|
||||||
self.total_messages = 0
|
self.total_messages = 0
|
||||||
self.current_message_id = 0
|
self.current_message_id = 0
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create a temporary message store for search results
|
# 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 = 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.envelopes = search_store.envelopes
|
||||||
|
self.message_store.metadata_by_id = search_store.metadata_by_id
|
||||||
self.total_messages = len(results)
|
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":
|
if item and item.get("type") == "header":
|
||||||
envelopes_list.append(ListItem(GroupHeader(label=item["label"])))
|
envelopes_list.append(ListItem(GroupHeader(label=item["label"])))
|
||||||
elif item:
|
elif item:
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ class ContentDisplayConfig(BaseModel):
|
|||||||
# View mode: "markdown" for pretty rendering, "html" for raw/plain display
|
# View mode: "markdown" for pretty rendering, "html" for raw/plain display
|
||||||
default_view_mode: Literal["markdown", "html"] = "markdown"
|
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):
|
class LinkPanelConfig(BaseModel):
|
||||||
"""Configuration for the link panel."""
|
"""Configuration for the link panel."""
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ EnvelopeListItem .status-icon.unread {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnvelopeListItem .checkbox {
|
EnvelopeListItem .checkbox {
|
||||||
width: 2;
|
width: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,12 +139,12 @@ EnvelopeListItem .message-datetime {
|
|||||||
|
|
||||||
EnvelopeListItem .email-subject {
|
EnvelopeListItem .email-subject {
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
padding: 0 4;
|
padding: 0 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
EnvelopeListItem .email-preview {
|
EnvelopeListItem .email-preview {
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
padding: 0 4;
|
padding: 0 3;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Input, Label, Button, ListView, ListItem
|
from textual.widgets import Input, Label, Button, ListView, ListItem
|
||||||
from textual.containers import Vertical, Horizontal, Container
|
from textual.containers import Vertical, Horizontal, Container
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual import on, work
|
from textual import on, work
|
||||||
from src.services.task_client import create_task, get_backend_info
|
from src.services.task_client import create_task, get_backend_info
|
||||||
|
from src.utils.ipc import notify_refresh
|
||||||
|
|
||||||
|
|
||||||
class CreateTaskScreen(ModalScreen):
|
class CreateTaskScreen(ModalScreen):
|
||||||
@@ -208,6 +210,8 @@ class CreateTaskScreen(ModalScreen):
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
self.app.show_status(f"Task created: {subject}", "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()
|
self.dismiss()
|
||||||
else:
|
else:
|
||||||
self.app.show_status(f"Failed to create task: {result}", "error")
|
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
|
- Keeping first and last path segments, eliding middle only if needed
|
||||||
- Adapting to available width
|
- Adapting to available width
|
||||||
"""
|
"""
|
||||||
|
# Nerdfont chevron separator (nf-cod-chevron_right)
|
||||||
|
sep = " \ueab6 "
|
||||||
|
|
||||||
# Special handling for common sites
|
# Special handling for common sites
|
||||||
path = path.strip("/")
|
path = path.strip("/")
|
||||||
|
|
||||||
@@ -95,26 +98,26 @@ class LinkItem:
|
|||||||
if match:
|
if match:
|
||||||
repo, type_, num = match.groups()
|
repo, type_, num = match.groups()
|
||||||
icon = "#" if type_ == "issues" else "PR#"
|
icon = "#" if type_ == "issues" else "PR#"
|
||||||
return f"{domain} > {repo} {icon}{num}"
|
return f"{domain}{sep}{repo} {icon}{num}"
|
||||||
|
|
||||||
match = re.match(r"([^/]+/[^/]+)", path)
|
match = re.match(r"([^/]+/[^/]+)", path)
|
||||||
if match:
|
if match:
|
||||||
return f"{domain} > {match.group(1)}"
|
return f"{domain}{sep}{match.group(1)}"
|
||||||
|
|
||||||
# Google Docs
|
# Google Docs
|
||||||
if "docs.google.com" in domain:
|
if "docs.google.com" in domain:
|
||||||
if "/document/" in path:
|
if "/document/" in path:
|
||||||
return f"{domain} > Document"
|
return f"{domain}{sep}Document"
|
||||||
if "/spreadsheets/" in path:
|
if "/spreadsheets/" in path:
|
||||||
return f"{domain} > Spreadsheet"
|
return f"{domain}{sep}Spreadsheet"
|
||||||
if "/presentation/" in path:
|
if "/presentation/" in path:
|
||||||
return f"{domain} > Slides"
|
return f"{domain}{sep}Slides"
|
||||||
|
|
||||||
# Jira/Atlassian
|
# Jira/Atlassian
|
||||||
if "atlassian.net" in domain or "jira" in domain.lower():
|
if "atlassian.net" in domain or "jira" in domain.lower():
|
||||||
match = re.search(r"([A-Z]+-\d+)", path)
|
match = re.search(r"([A-Z]+-\d+)", path)
|
||||||
if match:
|
if match:
|
||||||
return f"{domain} > {match.group(1)}"
|
return f"{domain}{sep}{match.group(1)}"
|
||||||
|
|
||||||
# GitLab
|
# GitLab
|
||||||
if "gitlab" in domain.lower():
|
if "gitlab" in domain.lower():
|
||||||
@@ -122,7 +125,7 @@ class LinkItem:
|
|||||||
if match:
|
if match:
|
||||||
repo, type_, num = match.groups()
|
repo, type_, num = match.groups()
|
||||||
icon = "#" if type_ == "issues" else "MR!"
|
icon = "#" if type_ == "issues" else "MR!"
|
||||||
return f"{domain} > {repo} {icon}{num}"
|
return f"{domain}{sep}{repo} {icon}{num}"
|
||||||
|
|
||||||
# Generic shortening - keep URL readable
|
# Generic shortening - keep URL readable
|
||||||
if len(url) <= max_len:
|
if len(url) <= max_len:
|
||||||
@@ -136,31 +139,31 @@ class LinkItem:
|
|||||||
|
|
||||||
# Try to fit the full path first
|
# Try to fit the full path first
|
||||||
full_path = "/".join(path_parts)
|
full_path = "/".join(path_parts)
|
||||||
result = f"{domain} > {full_path}"
|
result = f"{domain}{sep}{full_path}"
|
||||||
if len(result) <= max_len:
|
if len(result) <= max_len:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Keep first segment + last two segments if possible
|
# Keep first segment + last two segments if possible
|
||||||
if len(path_parts) >= 3:
|
if len(path_parts) >= 3:
|
||||||
short_path = f"{path_parts[0]}/.../{path_parts[-2]}/{path_parts[-1]}"
|
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:
|
if len(result) <= max_len:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Keep first + last segment
|
# Keep first + last segment
|
||||||
if len(path_parts) >= 2:
|
if len(path_parts) >= 2:
|
||||||
short_path = f"{path_parts[0]}/.../{path_parts[-1]}"
|
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:
|
if len(result) <= max_len:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Just last segment
|
# Just last segment
|
||||||
result = f"{domain} > .../{path_parts[-1]}"
|
result = f"{domain}{sep}.../{path_parts[-1]}"
|
||||||
if len(result) <= max_len:
|
if len(result) <= max_len:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Truncate with ellipsis as last resort
|
# Truncate with ellipsis as last resort
|
||||||
result = f"{domain} > {path_parts[-1]}"
|
result = f"{domain}{sep}{path_parts[-1]}"
|
||||||
if len(result) > max_len:
|
if len(result) > max_len:
|
||||||
result = result[: max_len - 3] + "..."
|
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
|
- Live search with 1 second debounce
|
||||||
- Cancel button to restore previous state
|
- Cancel button to restore previous state
|
||||||
- Help button showing Himalaya search syntax
|
- 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 typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
@@ -17,6 +19,34 @@ from textual.screen import ModalScreen
|
|||||||
from textual.timer import Timer
|
from textual.timer import Timer
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
from textual.widgets import Button, Input, Label, Static
|
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_HELP = """
|
||||||
## Himalaya Search Query Syntax
|
## Himalaya Search Query Syntax
|
||||||
@@ -106,6 +136,94 @@ class SearchHelpModal(ModalScreen[None]):
|
|||||||
self.dismiss(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):
|
class SearchPanel(Widget):
|
||||||
"""Docked search panel with live search capability."""
|
"""Docked search panel with live search capability."""
|
||||||
|
|
||||||
@@ -125,7 +243,7 @@ class SearchPanel(Widget):
|
|||||||
}
|
}
|
||||||
|
|
||||||
SearchPanel > Horizontal {
|
SearchPanel > Horizontal {
|
||||||
height: auto;
|
height: 3;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align: left middle;
|
align: left middle;
|
||||||
}
|
}
|
||||||
@@ -190,6 +308,7 @@ class SearchPanel(Widget):
|
|||||||
super().__init__(name=name, id=id, classes=classes)
|
super().__init__(name=name, id=id, classes=classes)
|
||||||
self._debounce_timer: Optional[Timer] = None
|
self._debounce_timer: Optional[Timer] = None
|
||||||
self._last_query: str = ""
|
self._last_query: str = ""
|
||||||
|
self._pending_date_keyword: Optional[str] = None # Track keyword awaiting date
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Horizontal():
|
with Horizontal():
|
||||||
@@ -197,11 +316,40 @@ class SearchPanel(Widget):
|
|||||||
yield Input(
|
yield Input(
|
||||||
placeholder="from <name> or subject <text> or body <text>...",
|
placeholder="from <name> or subject <text> or body <text>...",
|
||||||
id="search-input",
|
id="search-input",
|
||||||
|
suggester=SuggestFromList(HIMALAYA_KEYWORDS, case_sensitive=False),
|
||||||
)
|
)
|
||||||
yield Label("", classes="search-status", id="search-status")
|
yield Label("", classes="search-status", id="search-status")
|
||||||
yield Button("?", variant="default", id="help-btn")
|
yield Button("?", variant="default", id="help-btn")
|
||||||
yield Button("Cancel", variant="warning", id="cancel-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:
|
def show(self, initial_query: str = "") -> None:
|
||||||
"""Show the search panel and focus the input."""
|
"""Show the search panel and focus the input."""
|
||||||
self.add_class("visible")
|
self.add_class("visible")
|
||||||
@@ -216,6 +364,11 @@ class SearchPanel(Widget):
|
|||||||
self._cancel_debounce()
|
self._cancel_debounce()
|
||||||
self.result_count = -1
|
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
|
@property
|
||||||
def is_visible(self) -> bool:
|
def is_visible(self) -> bool:
|
||||||
"""Check if the panel is visible."""
|
"""Check if the panel is visible."""
|
||||||
@@ -234,12 +387,44 @@ class SearchPanel(Widget):
|
|||||||
|
|
||||||
def _trigger_search(self) -> None:
|
def _trigger_search(self) -> None:
|
||||||
"""Trigger the actual search after debounce."""
|
"""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()
|
query = self.query_one("#search-input", Input).value.strip()
|
||||||
if query and query != self._last_query:
|
if query and query != self._last_query:
|
||||||
self._last_query = query
|
self._last_query = query
|
||||||
self.is_searching = True
|
self.is_searching = True
|
||||||
self.post_message(self.SearchRequested(query))
|
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:
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
"""Handle input changes with debounce."""
|
"""Handle input changes with debounce."""
|
||||||
if event.input.id != "search-input":
|
if event.input.id != "search-input":
|
||||||
@@ -252,6 +437,12 @@ class SearchPanel(Widget):
|
|||||||
if not event.value.strip():
|
if not event.value.strip():
|
||||||
return
|
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)
|
# Set up new debounce timer (1 second)
|
||||||
self._debounce_timer = self.set_timer(1.0, self._trigger_search)
|
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 textual.reactive import reactive
|
||||||
from src.services.himalaya import client as himalaya_client
|
from src.services.himalaya import client as himalaya_client
|
||||||
from src.mail.config import get_config
|
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
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal, List
|
from typing import Literal, List, Dict
|
||||||
|
from urllib.parse import urlparse
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -18,6 +23,88 @@ import sys
|
|||||||
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__))))
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class EnvelopeHeader(Vertical):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
@@ -34,9 +121,12 @@ class EnvelopeHeader(Vertical):
|
|||||||
self.mount(self.to_label)
|
self.mount(self.to_label)
|
||||||
self.mount(self.cc_label)
|
self.mount(self.cc_label)
|
||||||
self.mount(self.date_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):
|
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.from_label.update(f"[b]From:[/b] {from_}")
|
||||||
self.to_label.update(f"[b]To:[/b] {to}")
|
self.to_label.update(f"[b]To:[/b] {to}")
|
||||||
|
|
||||||
@@ -192,10 +282,18 @@ class ContentContainer(ScrollableContainer):
|
|||||||
# Store the raw content for link extraction
|
# Store the raw content for link extraction
|
||||||
self.current_content = content
|
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:
|
try:
|
||||||
if self.current_mode == "markdown":
|
if self.current_mode == "markdown":
|
||||||
# For markdown mode, use the Markdown widget
|
# 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:
|
else:
|
||||||
# For HTML mode, use the Static widget with markup
|
# For HTML mode, use the Static widget with markup
|
||||||
# First, try to extract the body content if it's HTML
|
# First, try to extract the body content if it's HTML
|
||||||
|
|||||||
@@ -48,12 +48,7 @@ class EnvelopeListItem(Static):
|
|||||||
}
|
}
|
||||||
|
|
||||||
EnvelopeListItem .checkbox {
|
EnvelopeListItem .checkbox {
|
||||||
width: 2;
|
width: 1;
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
EnvelopeListItem .checkbox {
|
|
||||||
width: 2;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,47 @@ async def list_folders(
|
|||||||
return [], False
|
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(
|
async def delete_message(
|
||||||
message_id: int,
|
message_id: int,
|
||||||
folder: Optional[str] = None,
|
folder: Optional[str] = None,
|
||||||
@@ -312,6 +353,49 @@ async def mark_as_read(
|
|||||||
return str(e), False
|
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(
|
async def search_envelopes(
|
||||||
query: str,
|
query: str,
|
||||||
folder: Optional[str] = None,
|
folder: Optional[str] = None,
|
||||||
@@ -335,9 +419,33 @@ async def search_envelopes(
|
|||||||
- Success status (True if operation was successful)
|
- Success status (True if operation was successful)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Build a compound query to search from, to, subject, and body
|
# Himalaya query keywords that indicate the user is writing a raw query
|
||||||
# Himalaya query syntax: from <pattern> or to <pattern> or subject <pattern> or body <pattern>
|
query_keywords = (
|
||||||
search_query = f"from {query} or to {query} or subject {query} or body {query}"
|
"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}"
|
||||||
|
)
|
||||||
|
|
||||||
# Build command with options before the query (query must be at the end, quoted)
|
# Build command with options before the query (query must be at the end, quoted)
|
||||||
cmd = "himalaya envelope list -o json"
|
cmd = "himalaya envelope list -o json"
|
||||||
|
|||||||
@@ -330,3 +330,44 @@ class KhalClient(CalendarBackend):
|
|||||||
# khal edit is interactive, so this is limited via CLI
|
# khal edit is interactive, so this is limited via CLI
|
||||||
logger.warning("update_event not fully implemented for khal CLI")
|
logger.warning("update_event not fully implemented for khal CLI")
|
||||||
return None
|
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)
|
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):
|
def get_access_token(scopes):
|
||||||
"""
|
"""
|
||||||
Authenticate with Microsoft Graph API and obtain an access token.
|
Authenticate with Microsoft Graph API and obtain an access token.
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ async def fetch_mail_async(
|
|||||||
downloaded_count = 0
|
downloaded_count = 0
|
||||||
|
|
||||||
# Download messages in parallel batches for better performance
|
# 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):
|
for i in range(0, len(messages_to_download), BATCH_SIZE):
|
||||||
# Check if task was cancelled/disabled
|
# Check if task was cancelled/disabled
|
||||||
@@ -487,7 +488,8 @@ async def fetch_archive_mail_async(
|
|||||||
downloaded_count = 0
|
downloaded_count = 0
|
||||||
|
|
||||||
# Download messages in parallel batches for better performance
|
# 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):
|
for i in range(0, len(messages_to_download), BATCH_SIZE):
|
||||||
# Check if task was cancelled/disabled
|
# 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.binding import Binding
|
||||||
from textual.containers import ScrollableContainer, Vertical, Horizontal
|
from textual.containers import ScrollableContainer, Vertical, Horizontal
|
||||||
from textual.logging import TextualHandler
|
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 .config import get_config, TasksAppConfig
|
||||||
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
|
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
|
||||||
from .widgets.FilterSidebar import FilterSidebar
|
from .widgets.FilterSidebar import FilterSidebar
|
||||||
from src.utils.shared_config import get_theme_name
|
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
|
# 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__))))
|
||||||
@@ -153,6 +154,30 @@ class TasksApp(App):
|
|||||||
#notes-pane.hidden {
|
#notes-pane.hidden {
|
||||||
display: none;
|
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 = [
|
BINDINGS = [
|
||||||
@@ -174,6 +199,8 @@ class TasksApp(App):
|
|||||||
Binding("r", "refresh", "Refresh", show=True),
|
Binding("r", "refresh", "Refresh", show=True),
|
||||||
Binding("y", "sync", "Sync", show=True),
|
Binding("y", "sync", "Sync", show=True),
|
||||||
Binding("?", "help", "Help", 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),
|
Binding("enter", "view_task", "View", show=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -208,6 +235,7 @@ class TasksApp(App):
|
|||||||
self.notes_visible = False
|
self.notes_visible = False
|
||||||
self.detail_visible = False
|
self.detail_visible = False
|
||||||
self.sidebar_visible = True # Start with sidebar visible
|
self.sidebar_visible = True # Start with sidebar visible
|
||||||
|
self.current_search_query = "" # Current search filter
|
||||||
self.config = get_config()
|
self.config = get_config()
|
||||||
|
|
||||||
if backend:
|
if backend:
|
||||||
@@ -221,6 +249,12 @@ class TasksApp(App):
|
|||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Create the app layout."""
|
"""Create the app layout."""
|
||||||
yield Header()
|
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 FilterSidebar(id="sidebar")
|
||||||
yield Vertical(
|
yield Vertical(
|
||||||
DataTable(id="task-table", cursor_type="row"),
|
DataTable(id="task-table", cursor_type="row"),
|
||||||
@@ -246,6 +280,11 @@ class TasksApp(App):
|
|||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Initialize the app on mount."""
|
"""Initialize the app on mount."""
|
||||||
self.theme = get_theme_name()
|
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)
|
table = self.query_one("#task-table", DataTable)
|
||||||
|
|
||||||
# Setup columns based on config with dynamic widths
|
# Setup columns based on config with dynamic widths
|
||||||
@@ -271,6 +310,9 @@ class TasksApp(App):
|
|||||||
# Load tasks (filtered by current filters)
|
# Load tasks (filtered by current filters)
|
||||||
self.load_tasks()
|
self.load_tasks()
|
||||||
|
|
||||||
|
# Focus the task table (not the hidden search input)
|
||||||
|
table.focus()
|
||||||
|
|
||||||
def _setup_columns(self, table: DataTable, columns: list[str]) -> None:
|
def _setup_columns(self, table: DataTable, columns: list[str]) -> None:
|
||||||
"""Setup table columns with dynamic widths based on available space."""
|
"""Setup table columns with dynamic widths based on available space."""
|
||||||
# Minimum widths for each column type
|
# Minimum widths for each column type
|
||||||
@@ -408,10 +450,11 @@ class TasksApp(App):
|
|||||||
self._update_sidebar()
|
self._update_sidebar()
|
||||||
|
|
||||||
def _filter_tasks(self, tasks: list[Task]) -> list[Task]:
|
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 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 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
|
filtered = tasks
|
||||||
|
|
||||||
@@ -427,6 +470,18 @@ class TasksApp(App):
|
|||||||
if any(tag in t.tags for tag in self.current_tag_filters)
|
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
|
return filtered
|
||||||
|
|
||||||
def _update_sidebar(self) -> None:
|
def _update_sidebar(self) -> None:
|
||||||
@@ -485,6 +540,8 @@ class TasksApp(App):
|
|||||||
status_bar.total_tasks = len(self.tasks)
|
status_bar.total_tasks = len(self.tasks)
|
||||||
|
|
||||||
filters = []
|
filters = []
|
||||||
|
if self.current_search_query:
|
||||||
|
filters.append(f"\uf002 {self.current_search_query}") # nf-fa-search
|
||||||
if self.current_project_filter:
|
if self.current_project_filter:
|
||||||
filters.append(f"project:{self.current_project_filter}")
|
filters.append(f"project:{self.current_project_filter}")
|
||||||
for tag in self.current_tag_filters:
|
for tag in self.current_tag_filters:
|
||||||
@@ -754,9 +811,10 @@ class TasksApp(App):
|
|||||||
self.notify(f"Sorted by {event.column} ({direction})")
|
self.notify(f"Sorted by {event.column} ({direction})")
|
||||||
|
|
||||||
def action_clear_filters(self) -> None:
|
def action_clear_filters(self) -> None:
|
||||||
"""Clear all filters."""
|
"""Clear all filters including search."""
|
||||||
self.current_project_filter = None
|
self.current_project_filter = None
|
||||||
self.current_tag_filters = []
|
self.current_tag_filters = []
|
||||||
|
self.current_search_query = ""
|
||||||
|
|
||||||
# Also clear sidebar selections
|
# Also clear sidebar selections
|
||||||
try:
|
try:
|
||||||
@@ -765,6 +823,15 @@ class TasksApp(App):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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.load_tasks()
|
||||||
self.notify("Filters cleared", severity="information")
|
self.notify("Filters cleared", severity="information")
|
||||||
|
|
||||||
@@ -800,6 +867,8 @@ Keybindings:
|
|||||||
x - Delete task
|
x - Delete task
|
||||||
w - Toggle filter sidebar
|
w - Toggle filter sidebar
|
||||||
c - Clear all filters
|
c - Clear all filters
|
||||||
|
/ - Search tasks
|
||||||
|
Esc - Clear search
|
||||||
r - Refresh
|
r - Refresh
|
||||||
y - Sync with remote
|
y - Sync with remote
|
||||||
Enter - View task details
|
Enter - View task details
|
||||||
@@ -807,6 +876,56 @@ Keybindings:
|
|||||||
"""
|
"""
|
||||||
self.notify(help_text.strip(), timeout=10)
|
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
|
# Notes actions
|
||||||
def action_toggle_notes(self) -> None:
|
def action_toggle_notes(self) -> None:
|
||||||
"""Toggle the notes-only pane visibility."""
|
"""Toggle the notes-only pane visibility."""
|
||||||
@@ -897,6 +1016,18 @@ Keybindings:
|
|||||||
if task:
|
if task:
|
||||||
self._update_detail_display(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:
|
def run_app(backend: Optional[TaskBackend] = None) -> None:
|
||||||
"""Run the Tasks TUI application."""
|
"""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(
|
async def save_mime_to_maildir_async(
|
||||||
maildir_path,
|
maildir_path,
|
||||||
message,
|
message,
|
||||||
@@ -136,63 +161,68 @@ async def create_mime_message_async(
|
|||||||
|
|
||||||
# First try the direct body content approach
|
# First try the direct body content approach
|
||||||
message_id = message.get("id", "")
|
message_id = message.get("id", "")
|
||||||
|
|
||||||
|
# Get shared session for connection reuse
|
||||||
|
session = await get_shared_session()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First get the message with body content
|
# First get the message with body content
|
||||||
body_url = f"https://graph.microsoft.com/v1.0/me/messages/{message_id}?$select=body,bodyPreview"
|
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:
|
||||||
async with session.get(body_url, headers=headers) as response:
|
if response.status == 200:
|
||||||
if response.status == 200:
|
body_data = await response.json()
|
||||||
body_data = await response.json()
|
|
||||||
|
|
||||||
# Get body content
|
# Get body content
|
||||||
body_content = body_data.get("body", {}).get("content", "")
|
body_content = body_data.get("body", {}).get("content", "")
|
||||||
body_type = body_data.get("body", {}).get("contentType", "text")
|
body_type = body_data.get("body", {}).get("contentType", "text")
|
||||||
body_preview = body_data.get("bodyPreview", "")
|
body_preview = body_data.get("bodyPreview", "")
|
||||||
|
|
||||||
# If we have body content, use it
|
# If we have body content, use it
|
||||||
if body_content:
|
if body_content:
|
||||||
if body_type.lower() == "html":
|
if body_type.lower() == "html":
|
||||||
# Add both HTML and plain text versions
|
# Add both HTML and plain text versions
|
||||||
# Plain text conversion
|
# Plain text conversion
|
||||||
plain_text = re.sub(r"<br\s*/?>", "\n", body_content)
|
plain_text = re.sub(r"<br\s*/?>", "\n", body_content)
|
||||||
plain_text = re.sub(r"<[^>]*>", "", plain_text)
|
plain_text = re.sub(r"<[^>]*>", "", plain_text)
|
||||||
|
|
||||||
mime_msg.attach(MIMEText(plain_text, "plain"))
|
mime_msg.attach(MIMEText(plain_text, "plain"))
|
||||||
mime_msg.attach(MIMEText(body_content, "html"))
|
mime_msg.attach(MIMEText(body_content, "html"))
|
||||||
else:
|
|
||||||
# Just plain text
|
|
||||||
mime_msg.attach(MIMEText(body_content, "plain"))
|
|
||||||
elif body_preview:
|
|
||||||
# Use preview if we have it
|
|
||||||
mime_msg.attach(
|
|
||||||
MIMEText(
|
|
||||||
f"{body_preview}\n\n[Message preview only. Full content not available.]",
|
|
||||||
"plain",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Fallback to MIME content
|
# Just plain text
|
||||||
progress.console.print(
|
mime_msg.attach(MIMEText(body_content, "plain"))
|
||||||
f"No direct body content for message {truncate_id(message_id)}, trying MIME content..."
|
elif body_preview:
|
||||||
|
# Use preview if we have it
|
||||||
|
mime_msg.attach(
|
||||||
|
MIMEText(
|
||||||
|
f"{body_preview}\n\n[Message preview only. Full content not available.]",
|
||||||
|
"plain",
|
||||||
)
|
)
|
||||||
await fetch_mime_content(
|
|
||||||
mime_msg, message_id, headers, progress
|
|
||||||
)
|
|
||||||
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)
|
else:
|
||||||
|
# Fallback to MIME content
|
||||||
|
progress.console.print(
|
||||||
|
f"No direct body content for message {truncate_id(message_id)}, trying MIME content..."
|
||||||
|
)
|
||||||
|
await fetch_mime_content(
|
||||||
|
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, session
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
progress.console.print(
|
progress.console.print(
|
||||||
f"Error getting message body: {e}. Trying MIME content..."
|
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
|
# Handle attachments only if we want to download them
|
||||||
if download_attachments:
|
if download_attachments:
|
||||||
await add_attachments_async(
|
await add_attachments_async(
|
||||||
mime_msg, message, headers, attachments_dir, progress
|
mime_msg, message, headers, attachments_dir, progress, session
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Add a header to indicate attachment info was skipped
|
# Add a header to indicate attachment info was skipped
|
||||||
@@ -201,7 +231,7 @@ async def create_mime_message_async(
|
|||||||
return mime_msg
|
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.
|
Fetch and add MIME content to a message when direct body access fails.
|
||||||
|
|
||||||
@@ -210,72 +240,78 @@ async def fetch_mime_content(mime_msg, message_id, headers, progress):
|
|||||||
message_id (str): Message ID.
|
message_id (str): Message ID.
|
||||||
headers (dict): Headers including authentication.
|
headers (dict): Headers including authentication.
|
||||||
progress: Progress instance for updating progress bars.
|
progress: Progress instance for updating progress bars.
|
||||||
|
session (aiohttp.ClientSession, optional): Shared session to use.
|
||||||
"""
|
"""
|
||||||
# Fallback to getting the MIME content
|
# Fallback to getting the MIME content
|
||||||
message_content_url = (
|
message_content_url = (
|
||||||
f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/$value"
|
f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/$value"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
# Use provided session or get shared session
|
||||||
async with session.get(message_content_url, headers=headers) as response:
|
if session is None:
|
||||||
if response.status == 200:
|
session = await get_shared_session()
|
||||||
full_content = await response.text()
|
|
||||||
|
|
||||||
# Check for body tags
|
async with session.get(message_content_url, headers=headers) as response:
|
||||||
body_match = re.search(
|
if response.status == 200:
|
||||||
r"<body[^>]*>(.*?)</body>",
|
full_content = await response.text()
|
||||||
|
|
||||||
|
# Check for body tags
|
||||||
|
body_match = re.search(
|
||||||
|
r"<body[^>]*>(.*?)</body>",
|
||||||
|
full_content,
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if body_match:
|
||||||
|
body_content = body_match.group(1)
|
||||||
|
# Simple HTML to text conversion
|
||||||
|
body_text = re.sub(r"<br\s*/?>", "\n", body_content)
|
||||||
|
body_text = re.sub(r"<[^>]*>", "", body_text)
|
||||||
|
|
||||||
|
# Add the plain text body
|
||||||
|
mime_msg.attach(MIMEText(body_text, "plain"))
|
||||||
|
|
||||||
|
# Also add the HTML body
|
||||||
|
mime_msg.attach(MIMEText(full_content, "html"))
|
||||||
|
else:
|
||||||
|
# Fallback - try to find content between Content-Type: text/html and next boundary
|
||||||
|
html_parts = re.findall(
|
||||||
|
r"Content-Type: text/html.*?\r?\n\r?\n(.*?)(?:\r?\n\r?\n|$)",
|
||||||
full_content,
|
full_content,
|
||||||
re.DOTALL | re.IGNORECASE,
|
re.DOTALL | re.IGNORECASE,
|
||||||
)
|
)
|
||||||
if body_match:
|
if html_parts:
|
||||||
body_content = body_match.group(1)
|
html_content = html_parts[0]
|
||||||
# Simple HTML to text conversion
|
mime_msg.attach(MIMEText(html_content, "html"))
|
||||||
body_text = re.sub(r"<br\s*/?>", "\n", body_content)
|
|
||||||
body_text = re.sub(r"<[^>]*>", "", body_text)
|
|
||||||
|
|
||||||
# Add the plain text body
|
# Also make plain text version
|
||||||
mime_msg.attach(MIMEText(body_text, "plain"))
|
plain_text = re.sub(r"<br\s*/?>", "\n", html_content)
|
||||||
|
plain_text = re.sub(r"<[^>]*>", "", plain_text)
|
||||||
# Also add the HTML body
|
mime_msg.attach(MIMEText(plain_text, "plain"))
|
||||||
mime_msg.attach(MIMEText(full_content, "html"))
|
|
||||||
else:
|
else:
|
||||||
# Fallback - try to find content between Content-Type: text/html and next boundary
|
# Just use the raw content as text if nothing else works
|
||||||
html_parts = re.findall(
|
mime_msg.attach(MIMEText(full_content, "plain"))
|
||||||
r"Content-Type: text/html.*?\r?\n\r?\n(.*?)(?:\r?\n\r?\n|$)",
|
progress.console.print(
|
||||||
full_content,
|
f"Using raw content for message {message_id} - no body tags found"
|
||||||
re.DOTALL | re.IGNORECASE,
|
|
||||||
)
|
|
||||||
if html_parts:
|
|
||||||
html_content = html_parts[0]
|
|
||||||
mime_msg.attach(MIMEText(html_content, "html"))
|
|
||||||
|
|
||||||
# Also make plain text version
|
|
||||||
plain_text = re.sub(r"<br\s*/?>", "\n", html_content)
|
|
||||||
plain_text = re.sub(r"<[^>]*>", "", plain_text)
|
|
||||||
mime_msg.attach(MIMEText(plain_text, "plain"))
|
|
||||||
else:
|
|
||||||
# Just use the raw content as text if nothing else works
|
|
||||||
mime_msg.attach(MIMEText(full_content, "plain"))
|
|
||||||
progress.console.print(
|
|
||||||
f"Using raw content for message {message_id} - no body tags found"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
error_text = await response.text()
|
|
||||||
progress.console.print(
|
|
||||||
f"Failed to get MIME content: {response.status} {error_text}"
|
|
||||||
)
|
|
||||||
mime_msg.attach(
|
|
||||||
MIMEText(
|
|
||||||
f"Failed to retrieve message body: HTTP {response.status}",
|
|
||||||
"plain",
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
error_text = await response.text()
|
||||||
|
progress.console.print(
|
||||||
|
f"Failed to get MIME content: {response.status} {error_text}"
|
||||||
|
)
|
||||||
|
mime_msg.attach(
|
||||||
|
MIMEText(
|
||||||
|
f"Failed to retrieve message body: HTTP {response.status}",
|
||||||
|
"plain",
|
||||||
)
|
)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
progress.console.print(f"Error retrieving MIME content: {e}")
|
progress.console.print(f"Error retrieving MIME content: {e}")
|
||||||
mime_msg.attach(MIMEText(f"Failed to retrieve message body: {str(e)}", "plain"))
|
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.
|
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.
|
headers (dict): Headers including authentication.
|
||||||
attachments_dir (str): Path to save attachments.
|
attachments_dir (str): Path to save attachments.
|
||||||
progress: Progress instance for updating progress bars.
|
progress: Progress instance for updating progress bars.
|
||||||
|
session (aiohttp.ClientSession, optional): Shared session to use.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None
|
None
|
||||||
@@ -296,58 +333,57 @@ async def add_attachments_async(mime_msg, message, headers, attachments_dir, pro
|
|||||||
f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/attachments"
|
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
|
||||||
async with session.get(attachments_url, headers=headers) as response:
|
if session is None:
|
||||||
if response.status != 200:
|
session = await get_shared_session()
|
||||||
return
|
|
||||||
|
|
||||||
attachments_data = await response.json()
|
async with session.get(attachments_url, headers=headers) as response:
|
||||||
attachments = attachments_data.get("value", [])
|
if response.status != 200:
|
||||||
|
return
|
||||||
|
|
||||||
if not attachments:
|
attachments_data = await response.json()
|
||||||
return
|
attachments = attachments_data.get("value", [])
|
||||||
|
|
||||||
# Create a directory for this message's attachments
|
if not attachments:
|
||||||
message_attachments_dir = os.path.join(attachments_dir, message_id)
|
return
|
||||||
ensure_directory_exists(message_attachments_dir)
|
|
||||||
|
|
||||||
# Add a header with attachment count
|
# Create a directory for this message's attachments
|
||||||
mime_msg["X-Attachment-Count"] = str(len(attachments))
|
message_attachments_dir = os.path.join(attachments_dir, message_id)
|
||||||
|
ensure_directory_exists(message_attachments_dir)
|
||||||
|
|
||||||
for idx, attachment in enumerate(attachments):
|
# Add a header with attachment count
|
||||||
attachment_name = safe_filename(attachment.get("name", "attachment"))
|
mime_msg["X-Attachment-Count"] = str(len(attachments))
|
||||||
attachment_type = attachment.get(
|
|
||||||
"contentType", "application/octet-stream"
|
for idx, attachment in enumerate(attachments):
|
||||||
|
attachment_name = safe_filename(attachment.get("name", "attachment"))
|
||||||
|
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
|
||||||
|
mime_msg[f"X-Attachment-{idx + 1}-Type"] = attachment_type
|
||||||
|
|
||||||
|
attachment_part = MIMEBase(*attachment_type.split("/", 1))
|
||||||
|
|
||||||
|
# Get attachment content
|
||||||
|
if "contentBytes" in attachment:
|
||||||
|
attachment_content = base64.b64decode(attachment["contentBytes"])
|
||||||
|
|
||||||
|
# Save attachment to disk
|
||||||
|
attachment_path = os.path.join(message_attachments_dir, attachment_name)
|
||||||
|
with open(attachment_path, "wb") as f:
|
||||||
|
f.write(attachment_content)
|
||||||
|
|
||||||
|
# Add to MIME message
|
||||||
|
attachment_part.set_payload(attachment_content)
|
||||||
|
encoders.encode_base64(attachment_part)
|
||||||
|
attachment_part.add_header(
|
||||||
|
"Content-Disposition",
|
||||||
|
f'attachment; filename="{attachment_name}"',
|
||||||
)
|
)
|
||||||
|
mime_msg.attach(attachment_part)
|
||||||
|
|
||||||
# Add attachment info to headers for reference
|
progress.console.print(f"Downloaded attachment: {attachment_name}")
|
||||||
mime_msg[f"X-Attachment-{idx + 1}-Name"] = attachment_name
|
else:
|
||||||
mime_msg[f"X-Attachment-{idx + 1}-Type"] = attachment_type
|
progress.console.print(
|
||||||
|
f"Skipping attachment with no content: {attachment_name}"
|
||||||
attachment_part = MIMEBase(*attachment_type.split("/", 1))
|
)
|
||||||
|
|
||||||
# Get attachment content
|
|
||||||
if "contentBytes" in attachment:
|
|
||||||
attachment_content = base64.b64decode(attachment["contentBytes"])
|
|
||||||
|
|
||||||
# Save attachment to disk
|
|
||||||
attachment_path = os.path.join(
|
|
||||||
message_attachments_dir, attachment_name
|
|
||||||
)
|
|
||||||
with open(attachment_path, "wb") as f:
|
|
||||||
f.write(attachment_content)
|
|
||||||
|
|
||||||
# Add to MIME message
|
|
||||||
attachment_part.set_payload(attachment_content)
|
|
||||||
encoders.encode_base64(attachment_part)
|
|
||||||
attachment_part.add_header(
|
|
||||||
"Content-Disposition",
|
|
||||||
f'attachment; filename="{attachment_name}"',
|
|
||||||
)
|
|
||||||
mime_msg.attach(attachment_part)
|
|
||||||
|
|
||||||
progress.console.print(f"Downloaded attachment: {attachment_name}")
|
|
||||||
else:
|
|
||||||
progress.console.print(
|
|
||||||
f"Skipping attachment with no content: {attachment_name}"
|
|
||||||
)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user