Add calendar invites panel to Calendar TUI sidebar
- Create InvitesPanel widget showing pending invites from Microsoft Graph - Add fetch_pending_invites() and respond_to_invite() API functions - Invites load asynchronously in background on app mount - Display invite subject, date/time, and organizer - Add 'i' keybinding to focus invites panel - Style: tentative invites shown in warning color
This commit is contained in:
@@ -19,6 +19,7 @@ from textual.reactive import reactive
|
||||
from src.calendar.backend import CalendarBackend, Event
|
||||
from src.calendar.widgets.WeekGrid import WeekGrid
|
||||
from src.calendar.widgets.MonthCalendar import MonthCalendar
|
||||
from src.calendar.widgets.InvitesPanel import InvitesPanel, CalendarInvite
|
||||
from src.calendar.widgets.AddEventForm import EventFormData
|
||||
from src.utils.shared_config import get_theme_name
|
||||
|
||||
@@ -72,6 +73,13 @@ class CalendarApp(App):
|
||||
#sidebar-calendar {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#sidebar-invites {
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
border-top: solid $surface-darken-1;
|
||||
padding-top: 1;
|
||||
}
|
||||
|
||||
#week-grid {
|
||||
height: 1fr;
|
||||
@@ -120,6 +128,7 @@ class CalendarApp(App):
|
||||
Binding("g", "goto_today", "Today", show=True),
|
||||
Binding("w", "toggle_weekends", "Weekends", show=True),
|
||||
Binding("s", "toggle_sidebar", "Sidebar", show=True),
|
||||
Binding("i", "focus_invites", "Invites", show=True),
|
||||
Binding("r", "refresh", "Refresh", show=True),
|
||||
Binding("enter", "view_event", "View", show=True),
|
||||
Binding("a", "add_event", "Add", show=True),
|
||||
@@ -132,9 +141,11 @@ class CalendarApp(App):
|
||||
|
||||
# Instance attributes
|
||||
backend: Optional[CalendarBackend]
|
||||
_invites: list[CalendarInvite]
|
||||
|
||||
def __init__(self, backend: Optional[CalendarBackend] = None):
|
||||
super().__init__()
|
||||
self._invites = []
|
||||
|
||||
if backend:
|
||||
self.backend = backend
|
||||
@@ -150,6 +161,7 @@ class CalendarApp(App):
|
||||
with Horizontal(id="main-content"):
|
||||
with Vertical(id="sidebar"):
|
||||
yield MonthCalendar(id="sidebar-calendar")
|
||||
yield InvitesPanel(id="sidebar-invites")
|
||||
yield WeekGrid(id="week-grid")
|
||||
yield Static(id="event-detail", classes="hidden")
|
||||
yield CalendarStatusBar(id="status-bar")
|
||||
@@ -165,10 +177,75 @@ class CalendarApp(App):
|
||||
# Sync sidebar calendar with current week
|
||||
self._sync_sidebar_calendar()
|
||||
|
||||
# Load invites in background
|
||||
self.run_worker(self._load_invites_async(), exclusive=True)
|
||||
|
||||
# Update status bar and title
|
||||
self._update_status()
|
||||
self._update_title()
|
||||
|
||||
async def _load_invites_async(self) -> None:
|
||||
"""Load pending calendar invites from Microsoft Graph."""
|
||||
try:
|
||||
from src.services.microsoft_graph.auth import get_access_token
|
||||
from src.services.microsoft_graph.calendar import fetch_pending_invites
|
||||
from dateutil import parser as date_parser
|
||||
|
||||
# Get auth token
|
||||
scopes = ["https://graph.microsoft.com/Calendars.Read"]
|
||||
_, headers = get_access_token(scopes)
|
||||
|
||||
# Fetch invites
|
||||
raw_invites = await fetch_pending_invites(headers, days_forward=30)
|
||||
|
||||
# Convert to CalendarInvite objects
|
||||
invites = []
|
||||
for inv in raw_invites:
|
||||
try:
|
||||
start_str = inv.get("start", {}).get("dateTime", "")
|
||||
end_str = inv.get("end", {}).get("dateTime", "")
|
||||
start_dt = (
|
||||
date_parser.parse(start_str) if start_str else datetime.now()
|
||||
)
|
||||
end_dt = date_parser.parse(end_str) if end_str else start_dt
|
||||
|
||||
organizer_data = inv.get("organizer", {}).get("emailAddress", {})
|
||||
organizer_name = organizer_data.get(
|
||||
"name", organizer_data.get("address", "Unknown")
|
||||
)
|
||||
|
||||
invite = CalendarInvite(
|
||||
id=inv.get("id", ""),
|
||||
subject=inv.get("subject", "No Subject"),
|
||||
organizer=organizer_name,
|
||||
start=start_dt,
|
||||
end=end_dt,
|
||||
location=inv.get("location", {}).get("displayName"),
|
||||
is_all_day=inv.get("isAllDay", False),
|
||||
response_status=inv.get("responseStatus", {}).get(
|
||||
"response", "notResponded"
|
||||
),
|
||||
)
|
||||
invites.append(invite)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse invite: {e}")
|
||||
|
||||
# Update the panel
|
||||
self._invites = invites
|
||||
self.call_from_thread(self._update_invites_panel)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load invites: {e}")
|
||||
# Silently fail - invites are optional
|
||||
|
||||
def _update_invites_panel(self) -> None:
|
||||
"""Update the invites panel with loaded invites."""
|
||||
try:
|
||||
panel = self.query_one("#sidebar-invites", InvitesPanel)
|
||||
panel.set_invites(self._invites)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sync_sidebar_calendar(self) -> None:
|
||||
"""Sync the sidebar calendar with the main week grid."""
|
||||
try:
|
||||
@@ -443,6 +520,7 @@ Keybindings:
|
||||
g - Go to today
|
||||
w - Toggle weekends (5/7 days)
|
||||
s - Toggle sidebar
|
||||
i - Focus invites panel
|
||||
Enter - View event details
|
||||
a - Add new event
|
||||
r - Refresh
|
||||
@@ -450,6 +528,16 @@ Keybindings:
|
||||
"""
|
||||
self.notify(help_text.strip(), timeout=10)
|
||||
|
||||
def action_focus_invites(self) -> None:
|
||||
"""Focus on the invites panel and show invite count."""
|
||||
if not self.show_sidebar:
|
||||
self.action_toggle_sidebar()
|
||||
|
||||
if self._invites:
|
||||
self.notify(f"You have {len(self._invites)} pending invite(s)")
|
||||
else:
|
||||
self.notify("No pending invites")
|
||||
|
||||
|
||||
def run_app(backend: Optional[CalendarBackend] = None) -> None:
|
||||
"""Run the Calendar TUI application."""
|
||||
|
||||
209
src/calendar/widgets/InvitesPanel.py
Normal file
209
src/calendar/widgets/InvitesPanel.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Calendar invites panel widget for Calendar TUI sidebar.
|
||||
|
||||
Displays pending calendar invites that need a response.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.strip import Strip
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarInvite:
|
||||
"""A calendar invite pending response."""
|
||||
|
||||
id: str
|
||||
subject: str
|
||||
organizer: str
|
||||
start: datetime
|
||||
end: datetime
|
||||
location: Optional[str] = None
|
||||
is_all_day: bool = False
|
||||
response_status: str = "notResponded" # notResponded, tentativelyAccepted
|
||||
|
||||
|
||||
class InvitesPanel(Widget):
|
||||
"""Panel showing pending calendar invites."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
InvitesPanel {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 3;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
# Reactive attributes
|
||||
invites: reactive[List[CalendarInvite]] = reactive(list)
|
||||
selected_index: reactive[int] = reactive(0)
|
||||
|
||||
class InviteSelected(Message):
|
||||
"""An invite was selected."""
|
||||
|
||||
def __init__(self, invite: CalendarInvite) -> None:
|
||||
super().__init__()
|
||||
self.invite = invite
|
||||
|
||||
class InviteRespond(Message):
|
||||
"""User wants to respond to an invite."""
|
||||
|
||||
def __init__(self, invite: CalendarInvite, response: str) -> None:
|
||||
super().__init__()
|
||||
self.invite = invite
|
||||
self.response = response # accept, tentativelyAccept, decline
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
invites: Optional[List[CalendarInvite]] = None,
|
||||
name: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
classes: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
if invites:
|
||||
self.invites = invites
|
||||
|
||||
def _get_theme_color(self, color_name: str) -> str:
|
||||
"""Get a color from the current theme."""
|
||||
try:
|
||||
theme = self.app.current_theme
|
||||
color = getattr(theme, color_name, None)
|
||||
if color:
|
||||
return str(color)
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback colors
|
||||
fallbacks = {
|
||||
"secondary": "#81A1C1",
|
||||
"primary": "#88C0D0",
|
||||
"accent": "#B48EAD",
|
||||
"foreground": "#D8DEE9",
|
||||
"surface": "#3B4252",
|
||||
"warning": "#EBCB8B",
|
||||
}
|
||||
return fallbacks.get(color_name, "white")
|
||||
|
||||
def get_content_height(self, container, viewport, width: int) -> int:
|
||||
"""Calculate height: header + invite rows."""
|
||||
# Header (1) + invites (2 lines each) + empty message if no invites
|
||||
if not self.invites:
|
||||
return 2 # Header + "No pending invites"
|
||||
return 1 + len(self.invites) * 2 # Header + 2 lines per invite
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
"""Render a line of the panel."""
|
||||
if y == 0:
|
||||
return self._render_header()
|
||||
|
||||
if not self.invites:
|
||||
if y == 1:
|
||||
return self._render_empty_message()
|
||||
return Strip.blank(self.size.width)
|
||||
|
||||
# Each invite takes 2 lines
|
||||
invite_idx = (y - 1) // 2
|
||||
line_in_invite = (y - 1) % 2
|
||||
|
||||
if 0 <= invite_idx < len(self.invites):
|
||||
return self._render_invite_line(
|
||||
self.invites[invite_idx],
|
||||
line_in_invite,
|
||||
invite_idx == self.selected_index,
|
||||
)
|
||||
|
||||
return Strip.blank(self.size.width)
|
||||
|
||||
def _render_header(self) -> Strip:
|
||||
"""Render the panel header."""
|
||||
primary_color = self._get_theme_color("primary")
|
||||
count = len(self.invites)
|
||||
header = f"Invites ({count})" if count else "Invites"
|
||||
header = header[: self.size.width].ljust(self.size.width)
|
||||
style = Style(bold=True, color=primary_color)
|
||||
return Strip([Segment(header, style)])
|
||||
|
||||
def _render_empty_message(self) -> Strip:
|
||||
"""Render empty state message."""
|
||||
msg = "No pending invites"
|
||||
msg = msg[: self.size.width].ljust(self.size.width)
|
||||
style = Style(color="bright_black")
|
||||
return Strip([Segment(msg, style)])
|
||||
|
||||
def _render_invite_line(
|
||||
self, invite: CalendarInvite, line: int, is_selected: bool
|
||||
) -> Strip:
|
||||
"""Render a line of an invite."""
|
||||
if line == 0:
|
||||
# First line: subject (truncated)
|
||||
text = invite.subject[: self.size.width - 2]
|
||||
if is_selected:
|
||||
text = "> " + text[: self.size.width - 2]
|
||||
text = text[: self.size.width].ljust(self.size.width)
|
||||
|
||||
if is_selected:
|
||||
style = Style(bold=True, reverse=True)
|
||||
else:
|
||||
style = Style()
|
||||
|
||||
return Strip([Segment(text, style)])
|
||||
else:
|
||||
# Second line: date/time and organizer
|
||||
date_str = invite.start.strftime("%m/%d %H:%M")
|
||||
organizer = invite.organizer[:15] if invite.organizer else ""
|
||||
info = f" {date_str} - {organizer}"
|
||||
info = info[: self.size.width].ljust(self.size.width)
|
||||
|
||||
warning_color = self._get_theme_color("warning")
|
||||
if invite.response_status == "tentativelyAccepted":
|
||||
style = Style(color=warning_color, italic=True)
|
||||
else:
|
||||
style = Style(color="bright_black")
|
||||
|
||||
return Strip([Segment(info, style)])
|
||||
|
||||
def set_invites(self, invites: List[CalendarInvite]) -> None:
|
||||
"""Update the list of invites."""
|
||||
self.invites = invites
|
||||
if self.selected_index >= len(invites):
|
||||
self.selected_index = max(0, len(invites) - 1)
|
||||
self.refresh()
|
||||
|
||||
def select_next(self) -> None:
|
||||
"""Select the next invite."""
|
||||
if self.invites and self.selected_index < len(self.invites) - 1:
|
||||
self.selected_index += 1
|
||||
self.refresh()
|
||||
|
||||
def select_previous(self) -> None:
|
||||
"""Select the previous invite."""
|
||||
if self.invites and self.selected_index > 0:
|
||||
self.selected_index -= 1
|
||||
self.refresh()
|
||||
|
||||
def get_selected_invite(self) -> Optional[CalendarInvite]:
|
||||
"""Get the currently selected invite."""
|
||||
if self.invites and 0 <= self.selected_index < len(self.invites):
|
||||
return self.invites[self.selected_index]
|
||||
return None
|
||||
|
||||
def on_click(self, event) -> None:
|
||||
"""Handle mouse clicks."""
|
||||
y = event.y
|
||||
|
||||
if y == 0 or not self.invites:
|
||||
return
|
||||
|
||||
# Calculate which invite was clicked
|
||||
invite_idx = (y - 1) // 2
|
||||
if 0 <= invite_idx < len(self.invites):
|
||||
self.selected_index = invite_idx
|
||||
self.post_message(self.InviteSelected(self.invites[invite_idx]))
|
||||
self.refresh()
|
||||
@@ -3,5 +3,13 @@
|
||||
from .WeekGrid import WeekGrid
|
||||
from .AddEventForm import AddEventForm, EventFormData
|
||||
from .MonthCalendar import MonthCalendar
|
||||
from .InvitesPanel import InvitesPanel, CalendarInvite
|
||||
|
||||
__all__ = ["WeekGrid", "AddEventForm", "EventFormData", "MonthCalendar"]
|
||||
__all__ = [
|
||||
"WeekGrid",
|
||||
"AddEventForm",
|
||||
"EventFormData",
|
||||
"MonthCalendar",
|
||||
"InvitesPanel",
|
||||
"CalendarInvite",
|
||||
]
|
||||
|
||||
@@ -468,3 +468,76 @@ async def sync_local_calendar_changes(
|
||||
)
|
||||
|
||||
return created_count, deleted_count
|
||||
|
||||
|
||||
async def fetch_pending_invites(headers, days_forward=30):
|
||||
"""
|
||||
Fetch calendar invites that need a response (pending/tentative).
|
||||
|
||||
Args:
|
||||
headers (dict): Headers including authentication.
|
||||
days_forward (int): Number of days to look forward.
|
||||
|
||||
Returns:
|
||||
list: List of invite dictionaries with response status info.
|
||||
"""
|
||||
start_date = datetime.now()
|
||||
end_date = start_date + timedelta(days=days_forward)
|
||||
|
||||
start_date_str = start_date.strftime("%Y-%m-%dT00:00:00Z")
|
||||
end_date_str = end_date.strftime("%Y-%m-%dT23:59:59Z")
|
||||
|
||||
# Fetch events with response status
|
||||
calendar_url = (
|
||||
f"https://graph.microsoft.com/v1.0/me/calendarView?"
|
||||
f"startDateTime={start_date_str}&endDateTime={end_date_str}&"
|
||||
f"$select=id,subject,organizer,start,end,location,isAllDay,responseStatus,isCancelled&"
|
||||
f"$filter=responseStatus/response eq 'notResponded' or responseStatus/response eq 'tentativelyAccepted'&"
|
||||
f"$orderby=start/dateTime"
|
||||
)
|
||||
|
||||
invites = []
|
||||
|
||||
try:
|
||||
response_data = await fetch_with_aiohttp(calendar_url, headers)
|
||||
invites.extend(response_data.get("value", []))
|
||||
|
||||
# Handle pagination
|
||||
next_link = response_data.get("@odata.nextLink")
|
||||
while next_link:
|
||||
response_data = await fetch_with_aiohttp(next_link, headers)
|
||||
invites.extend(response_data.get("value", []))
|
||||
next_link = response_data.get("@odata.nextLink")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching pending invites: {e}")
|
||||
|
||||
return invites
|
||||
|
||||
|
||||
async def respond_to_invite(headers, event_id, response):
|
||||
"""
|
||||
Respond to a calendar invite.
|
||||
|
||||
Args:
|
||||
headers (dict): Authentication headers
|
||||
event_id (str): The ID of the event to respond to
|
||||
response (str): Response type - 'accept', 'tentativelyAccept', or 'decline'
|
||||
|
||||
Returns:
|
||||
bool: True if response was successful
|
||||
"""
|
||||
valid_responses = ["accept", "tentativelyAccept", "decline"]
|
||||
if response not in valid_responses:
|
||||
print(f"Invalid response type: {response}. Must be one of {valid_responses}")
|
||||
return False
|
||||
|
||||
try:
|
||||
response_url = (
|
||||
f"https://graph.microsoft.com/v1.0/me/events/{event_id}/{response}"
|
||||
)
|
||||
status = await post_with_aiohttp(response_url, headers, {})
|
||||
return status in (200, 202)
|
||||
except Exception as e:
|
||||
print(f"Error responding to invite: {e}")
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user